Compare commits
273 Commits
Author | SHA1 | Date |
---|---|---|
Heimen Stoffels | 0b0663e1aa | |
Jhoan Sebastian Espinosa Borrero | 7c0032133e | |
@liimee | 70fdfe3236 | |
@liimee | 7067c6807a | |
ghose | b024264750 | |
adil | 232fc0aae1 | |
emptyList() | e59a369661 | |
Sergio Varela | 5d4944e87a | |
ghose | e1d41ed675 | |
Allan Nordhøy | 9d741bc6eb | |
Allan Nordhøy | ec1e4ff629 | |
milotype | dfe9783048 | |
Homer S | bfa7a99015 | |
Homer S | 8cd278be7f | |
Dignified Silence | 9a1dfe59ad | |
Dignified Silence | 6f24d4906b | |
Dignified Silence | 1bf7f2ef38 | |
Creak | db171fb406 | |
Ryan Harg | 5cfb0cbdaf | |
helabasa | a77d3c0222 | |
helabasa | fe014cde1a | |
x | 641ead2c53 | |
Luka Filipović | 4972d0de47 | |
Luka Filipović | c6899d7254 | |
David | dccb3f3520 | |
Storozhenko Evgeny Vladimirovich | 5febaa5837 | |
anonymous | ff8eabb514 | |
vicdorke | 509133c654 | |
Philipp Wolfer | f630b0165c | |
Daniel | 0d39d1f628 | |
ghose | 8eac040142 | |
x | d46d599fc3 | |
x | a1cf0c5f5b | |
ghose | f188b5c449 | |
ghose | 13cd81825a | |
serxoz | 1397bdd449 | |
Daniel | 95fd3a0a6a | |
Dominik Danelski | ef67fa65c0 | |
Dominik Danelski | e20e941c7e | |
vicdorke | 6d718747db | |
Antoine POPINEAU | a7b469d690 | |
Antoine POPINEAU | 6bdefa1936 | |
Antoine POPINEAU | 785fa6ce19 | |
Antoine POPINEAU | 300cc54e97 | |
Antoine POPINEAU | 7feac4e400 | |
Antoine POPINEAU | b0747658ae | |
Antoine POPINEAU | ab654a08c4 | |
Antoine POPINEAU | d2a981c368 | |
Antoine POPINEAU | 049822005e | |
Antoine POPINEAU | d796fca26b | |
Antoine POPINEAU | 54d4dc2235 | |
Antoine POPINEAU | 64ea222f08 | |
Ventura Pérez García | 1380d1d2b9 | |
Antoine POPINEAU | e60814d28f | |
Antoine POPINEAU | ce8d956cee | |
Ventura Pérez García | 1e73ef6ee4 | |
Antoine POPINEAU | 63c8dbe09e | |
Antoine POPINEAU | 9b0c8b0bf6 | |
Antoine POPINEAU | b87766dad2 | |
Antoine POPINEAU | 50c8dac297 | |
Antoine POPINEAU | 1dd38e87fb | |
Antoine POPINEAU | 4cf77404a1 | |
Antoine POPINEAU | 9beb5e6641 | |
Antoine POPINEAU | 998dab0fb5 | |
dulz | 0056faee8e | |
Arne | 964c510312 | |
Keunes | f999745a0c | |
Antoine POPINEAU | 002ebec7ce | |
Antoine POPINEAU | d76f76a222 | |
Antoine POPINEAU | f062e62299 | |
Antoine POPINEAU | d2e472d770 | |
vicdorke | 04d0dd9c09 | |
Arne | 3dafb1c51f | |
anonymous | cbb147fc4b | |
Arne Schlag | 748ef0d935 | |
anonymous | 2c672ecbfa | |
Arne Schlag | 79140f829e | |
anonymous | 326fcefa62 | |
Arne Schlag | 2c657ee85a | |
Arne Schlag | c2ac66d992 | |
anonymous | 684e11d904 | |
Bread Factory | 0bec180cc5 | |
Bread Factory | 89db2a3880 | |
Antoine POPINEAU | a7968e9a87 | |
Antoine POPINEAU | 5c684b6e67 | |
Antoine POPINEAU | 85e9f14e2a | |
Antoine POPINEAU | 1e62cc1f4e | |
Antoine POPINEAU | b0640cf1b2 | |
Antoine POPINEAU | e7cb5e4c6e | |
Antoine POPINEAU | 7035f073f2 | |
Antoine POPINEAU | 931cd0b42d | |
Antoine POPINEAU | ba31a4efcf | |
Antoine POPINEAU | 9fb9d45e05 | |
Antoine POPINEAU | 8d7836172b | |
Antoine POPINEAU | 308e7d7567 | |
Antoine POPINEAU | 7d95618ff5 | |
Antoine POPINEAU | e4da4af3f3 | |
Antoine POPINEAU | b9e9272336 | |
Antoine POPINEAU | 61fdb116ad | |
Antoine POPINEAU | d75e8ae17f | |
Antoine POPINEAU | dd86988518 | |
Antoine POPINEAU | b6b9e4c053 | |
Antoine POPINEAU | eb6b7a807b | |
Antoine POPINEAU | 3a81d26cd9 | |
Antoine POPINEAU | 28949a8e17 | |
Antoine POPINEAU | bc1e911b41 | |
Antoine POPINEAU | 57692f2e42 | |
Antoine POPINEAU | fe224b097a | |
Antoine POPINEAU | 080c07eeab | |
Antoine POPINEAU | b34810d631 | |
Antoine POPINEAU | b14b703f05 | |
Antoine POPINEAU | 4ecb607f45 | |
Antoine POPINEAU | a3f84cc56c | |
Antoine POPINEAU | 4b2cf10e78 | |
Antoine POPINEAU | 5d397ab1fe | |
Antoine POPINEAU | f3bbca9c27 | |
Antoine POPINEAU | 37d5c7b7be | |
Antoine POPINEAU | 97bb621d7f | |
Antoine POPINEAU | b2e6ec43a8 | |
Antoine POPINEAU | de0a494b43 | |
Antoine POPINEAU | 0facf09c94 | |
Antoine POPINEAU | 2c4f8a4329 | |
Antoine POPINEAU | e17c706ae3 | |
Antoine POPINEAU | 37f4b1da9e | |
Antoine POPINEAU | b0d7ff393d | |
Antoine POPINEAU | a3f74af076 | |
Antoine POPINEAU | 34ddef8489 | |
Antoine POPINEAU | 7f6b748032 | |
Antoine POPINEAU | c5a63f88da | |
Antoine POPINEAU | 1a105654f0 | |
Antoine POPINEAU | 8b4537217b | |
Antoine POPINEAU | 1238931384 | |
Antoine POPINEAU | 100514cde6 | |
Antoine POPINEAU | 72ba8733b3 | |
Antoine POPINEAU | 49f5754f2b | |
Antoine POPINEAU | 9b888ba17f | |
Antoine POPINEAU | 212b44a22f | |
Antoine POPINEAU | 441ca3249c | |
Antoine POPINEAU | c420f26b88 | |
Antoine POPINEAU | 921154edbb | |
Antoine POPINEAU | 9c61fcf462 | |
Antoine POPINEAU | eb57b4c872 | |
Antoine POPINEAU | 9dbaf509c2 | |
Antoine POPINEAU | bedae61646 | |
Antoine POPINEAU | f7a5a29eea | |
Antoine POPINEAU | 2b9eb789e8 | |
Antoine POPINEAU | b2d26a8127 | |
Antoine POPINEAU | dc25a922c2 | |
Antoine POPINEAU | 1ee9f021ce | |
Antoine POPINEAU | 7a72558d1a | |
Antoine POPINEAU | ff2a915ba4 | |
Antoine POPINEAU | 03fcf1a382 | |
Antoine POPINEAU | 08a7a28c22 | |
Antoine POPINEAU | 3a88e02ca0 | |
Antoine POPINEAU | bab7040b8f | |
Antoine POPINEAU | 874b79d0d5 | |
Antoine POPINEAU | 671940ed7a | |
Antoine POPINEAU | 4d6b3d1ab2 | |
Antoine POPINEAU | a19e500f09 | |
Antoine POPINEAU | 490de25b05 | |
Antoine POPINEAU | 18e981fba5 | |
Antoine POPINEAU | 1b98850a9c | |
Antoine POPINEAU | 66c7915307 | |
Antoine POPINEAU | e539cc26dd | |
vicdorke | 2eff3263d2 | |
vicdorke | fb22b9f79e | |
Antoine POPINEAU | 13f3c2d465 | |
Antoine POPINEAU | 098048ac49 | |
Antoine POPINEAU | abff279df9 | |
Antoine POPINEAU | a2c35595c7 | |
Antoine POPINEAU | 94fd3d51aa | |
Antoine POPINEAU | a2caba8bd1 | |
Antoine POPINEAU | 4127421132 | |
Antoine POPINEAU | 00fb833cfa | |
Antoine POPINEAU | 2dfabf74e9 | |
Antoine POPINEAU | a0e201e68f | |
Antoine POPINEAU | 746ae8897d | |
Antoine POPINEAU | 58215da685 | |
Antoine POPINEAU | fc1419c2fb | |
Antoine POPINEAU | dcc6da655f | |
Antoine POPINEAU | e7865004af | |
Antoine POPINEAU | 17dace030e | |
Antoine POPINEAU | 94dec8367f | |
Antoine POPINEAU | 81cf0835df | |
Antoine POPINEAU | 725be6f8e5 | |
Antoine POPINEAU | 4b552acacb | |
Antoine POPINEAU | d496a83387 | |
Antoine POPINEAU | aa9f2f03d7 | |
Keunes | 7dc4d3e0ee | |
Antoine POPINEAU | 7bc3ab2010 | |
Antoine POPINEAU | 865603634a | |
Antoine POPINEAU | bfb1b90781 | |
Antoine POPINEAU | d319705234 | |
Antoine POPINEAU | 827170c34f | |
Antoine POPINEAU | eb97c3d4be | |
Antoine POPINEAU | 6dcd9afc31 | |
Antoine POPINEAU | d76f820f9d | |
Antoine POPINEAU | e50a43a812 | |
Antoine POPINEAU | a4b2907c07 | |
Antoine POPINEAU | 54f911793a | |
Antoine POPINEAU | 97b7dc2a61 | |
Keunes | 72a77221e6 | |
Antoine POPINEAU | 427dc8b4db | |
Antoine POPINEAU | a4b2af7640 | |
Arne Schlag | 931c9d589b | |
Antoine POPINEAU | c75f2e45f6 | |
Antoine POPINEAU | cb43615cb1 | |
Antoine POPINEAU | dfeec64bf1 | |
Antoine POPINEAU | 80554796d3 | |
Antoine POPINEAU | ce05acad21 | |
Antoine POPINEAU | dc7803acb4 | |
Antoine POPINEAU | 7f2f81f0a8 | |
Antoine POPINEAU | 5f2cad4c42 | |
Antoine POPINEAU | bf7763c8c3 | |
Antoine POPINEAU | 1ccfedca87 | |
Derek Schmidt | c9159166d2 | |
Antoine POPINEAU | d5c1b89d3d | |
Antoine POPINEAU | ae903aba65 | |
Antoine POPINEAU | 2d3bcde242 | |
Antoine POPINEAU | 64c8887dcb | |
Antoine POPINEAU | 307ecc4128 | |
Antoine POPINEAU | d0d64bad9d | |
Antoine POPINEAU | 06f8ddf931 | |
Antoine POPINEAU | fd1741ca53 | |
Antoine POPINEAU | 3fb0bb55a4 | |
Antoine POPINEAU | 3180c886a2 | |
Antoine POPINEAU | 415be3d235 | |
Antoine POPINEAU | 159685bcc1 | |
Antoine POPINEAU | 1038ee00ff | |
Antoine POPINEAU | 98b2b31e20 | |
Antoine POPINEAU | cf4cd16bed | |
Antoine POPINEAU | b554678500 | |
Antoine POPINEAU | fa82f13a9c | |
Antoine POPINEAU | 55ab0ce71c | |
Antoine POPINEAU | 9d0ee7f1b8 | |
Antoine POPINEAU | d9b7ed5b3f | |
Antoine POPINEAU | d53bee8f31 | |
Antoine POPINEAU | 534e48e2c8 | |
Antoine POPINEAU | aad0ec439c | |
Antoine POPINEAU | 3101fa5302 | |
Antoine POPINEAU | a55986343f | |
Antoine POPINEAU | aaf8874699 | |
Antoine POPINEAU | fbe5ea4db9 | |
Antoine POPINEAU | b7db24ea11 | |
Antoine POPINEAU | 40117122c7 | |
Antoine POPINEAU | 9ea5446f58 | |
Antoine POPINEAU | c36616ab92 | |
Antoine POPINEAU | 0cb4bda212 | |
Antoine POPINEAU | cac32332e0 | |
Antoine POPINEAU | 09ada772e6 | |
Antoine POPINEAU | 4d9fb1c53c | |
Antoine POPINEAU | 02715389d2 | |
Antoine POPINEAU | e4e91cd013 | |
Antoine POPINEAU | b735e20fbd | |
Antoine POPINEAU | 98b7812a47 | |
Antoine POPINEAU | a21cafdbe0 | |
Antoine POPINEAU | ba12854e6c | |
Antoine POPINEAU | 4e60906fe9 | |
Antoine POPINEAU | 2c87e7c983 | |
Antoine POPINEAU | 7084be81de | |
Antoine POPINEAU | 3cd1d77b85 | |
Antoine POPINEAU | 28d395e1da | |
Antoine POPINEAU | 28761b63c0 | |
Antoine POPINEAU | 668394e798 | |
Antoine POPINEAU | 22c99d384c | |
Antoine POPINEAU | c180456e9d | |
Antoine POPINEAU | 657c72480e | |
Antoine POPINEAU | bf1bba1162 | |
Antoine POPINEAU | 993e780d54 | |
Antoine POPINEAU | 43ffffa68f | |
Antoine POPINEAU | d40a2e3702 | |
Antoine POPINEAU | eac875b9cb | |
Antoine POPINEAU | 5c1498bb95 |
|
@ -0,0 +1,2 @@
|
|||
[*.{kt,kts}]
|
||||
indent_size=2
|
|
@ -0,0 +1,4 @@
|
|||
github: ["apognu"]
|
||||
custom: [
|
||||
"https://www.paypal.me/apognu"
|
||||
]
|
|
@ -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.
|
|
@ -0,0 +1,26 @@
|
|||
name: Continuous develop build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 1.8
|
||||
- name: Build with Gradle
|
||||
run: |
|
||||
mkdir -p /home/runner/.android && touch /home/runner/.android/repositories.cfg
|
||||
./gradlew assembleDebug
|
||||
- name: Create release
|
||||
uses: eine/tip@gha-tip
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cwd: ${{ github.workspace }}
|
||||
files: app/build/outputs/apk/debug/app-debug.apk
|
|
@ -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
|
38
.travis.yml
|
@ -1,38 +0,0 @@
|
|||
language: android
|
||||
dist: trusty
|
||||
|
||||
android:
|
||||
components:
|
||||
- platform-tools
|
||||
- tools
|
||||
- build-tools-29.0.2
|
||||
- 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"
|
||||
|
||||
script:
|
||||
- "./gradlew app:assembleDebug"
|
||||
|
||||
before_deploy:
|
||||
- RELEASE_MESSAGE="$(git tag -ln --format '%(subject)' $TRAVIS_TAG)"
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
name: $TRAVIS_TAG
|
||||
body: $RELEASE_MESSAGE
|
||||
prerelease: true
|
||||
file: app/build/outputs/apk/debug/app-debug.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=
|
|
@ -0,0 +1,41 @@
|
|||
# Otter for Funkwhale
|
||||
|
||||
![](https://img.shields.io/github/license/apognu/otter?style=flat-square)
|
||||
[![](https://img.shields.io/github/workflow/status/apognu/otter/Continuous%20develop%20build?label=develop&style=flat-square)](https://github.com/apognu/otter/actions?query=workflow%3A%22Continuous+develop+build%22)
|
||||
[![](https://img.shields.io/badge/Play%20Store-otter-informational?style=flat-square)](https://play.google.com/store/apps/details?id=com.github.apognu.otter)
|
||||
[![](https://img.shields.io/badge/IzzySoft-otter-informational?style=flat-square)](https://apt.izzysoft.de/fdroid/index/apk/com.github.apognu.otter)
|
||||
[![](https://img.shields.io/badge/APK-otter-informational?style=flat-square)](https://github.com/apognu/otter/releases) [![](https://translate.funkwhale.audio/widgets/otter/-/android/svg-badge.svg)](https://translate.funkwhale.audio/projects/otter/android/)
|
||||
|
||||
Otter is a native Android music player for [Funkwhale](https://funkwhale.audio), native to both Android (developed in Kotlin) and to Funkwhale (uses its native API instead of Subsonic).
|
||||
|
||||
You can get help and discuss Otter on Matrix on [#otter:matrix.org](https://matrix.to/#/#otter:matrix.org).
|
||||
|
||||
![Otter graphic](https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png)
|
||||
|
||||
## State
|
||||
|
||||
A beta version of the app can be downloaded on [Google Play](https://play.google.com/store/apps/details?id=com.github.apognu.otter), on [IzzySoft](https://apt.izzysoft.de/fdroid/index/apk/com.github.apognu.otter) (F-Droid-compatible repository) or through [GitHub releases](https://github.com/apognu/otter/releases). Please bear with it, there **will** be bugs, there **will** be crashes and there **will** be performance or UX issues.
|
||||
|
||||
Otter's features, as of this writing, are the following:
|
||||
|
||||
* Basic collection browsing (artists, albums and tracks)
|
||||
* Playlists listing
|
||||
* Favorites management (listing and add/remove)
|
||||
* Track search
|
||||
* Queue management
|
||||
* Caching of played tracks (played tracks work offline)
|
||||
* Download tracks for offline playback
|
||||
* Radios playback
|
||||
* Dark mode! 🎉
|
||||
|
||||
Otter will try to behave as you would expect a mobile music player to, meaning integrating with the OS's media controls (including headset controls) or pause on incoming calls. If there is anything you would like it to do, please [open an issue](https://github.com/apognu/otter/issues/new).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/6.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/7.png" width="200" />
|
||||
|
||||
## Translation
|
||||
|
||||
Otter is being translated by the community through [Weblate](https://translate.funkwhale.audio/projects/otter/android/). If you would like to contribute to its localization or add a new language, you can help out there.
|
||||
|
||||
Thanks to the Funkwhale project for hosting us on their instance.
|
|
@ -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,8 +13,15 @@ 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 {
|
||||
codeFormat = "MNNNPPP"
|
||||
format = "%tag%%-count%%-commit%%-branch%"
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -26,12 +31,10 @@ android {
|
|||
}
|
||||
|
||||
kotlinOptions {
|
||||
(this as KotlinJvmOptions).apply {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
}
|
||||
|
||||
buildToolsVersion = "29.0.2"
|
||||
buildToolsVersion = "29.0.3"
|
||||
compileSdkVersion(29)
|
||||
|
||||
defaultConfig {
|
||||
|
@ -40,6 +43,8 @@ android {
|
|||
minSdkVersion(23)
|
||||
targetSdkVersion(29)
|
||||
|
||||
ndkVersion = "21.3.6528147"
|
||||
|
||||
versionCode = androidGitVersion.code()
|
||||
versionName = androidGitVersion.name()
|
||||
}
|
||||
|
@ -56,19 +61,40 @@ 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
|
||||
|
||||
proguardFile(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
proguardFile("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidGitVersion {
|
||||
codeFormat = "MNNPP"
|
||||
resValue("string", "debug.hostname", "")
|
||||
resValue("string", "debug.username", "")
|
||||
resValue("string", "debug.password", "")
|
||||
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
|
@ -87,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.50")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.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.2.0-beta01")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.0.0")
|
||||
implementation("androidx.preference:preference:1.1.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.0.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||
implementation("com.google.android.material:material:1.2.0-alpha01")
|
||||
implementation("com.android.support.constraint:constraint-layout:1.1.3")
|
||||
implementation("androidx.appcompat:appcompat:1.2.0")
|
||||
implementation("androidx.core:core-ktx:1.5.0-alpha02")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
|
||||
implementation("androidx.preference:preference:1.1.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.1.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("com.google.android.material:material:1.3.0-alpha02")
|
||||
implementation("com.android.support.constraint:constraint-layout:2.0.1")
|
||||
|
||||
implementation("com.google.android.exoplayer:exoplayer: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")
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
-keep class com.github.apognu.otter.** { *; }
|
|
@ -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:screenOrientation="portrait"
|
||||
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>
|
||||
|
||||
|
|
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 23 KiB |
|
@ -2,13 +2,22 @@ package com.github.apognu.otter
|
|||
|
||||
import android.app.Application
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
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.*
|
||||
|
||||
class Otter : Application() {
|
||||
companion object {
|
||||
|
@ -17,14 +26,50 @@ class Otter : Application() {
|
|||
fun get(): Otter = instance
|
||||
}
|
||||
|
||||
var eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
|
||||
val commandBus: Channel<Command> = Channel(10)
|
||||
var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
|
||||
val commandBus: BroadcastChannel<Command> = BroadcastChannel(10)
|
||||
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
|
||||
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
|
||||
|
||||
private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) }
|
||||
|
||||
val exoCache: SimpleCache by lazy {
|
||||
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().let {
|
||||
val cacheSize = if (it == 0L) 0 else it * 1024 * 1024 * 1024
|
||||
|
||||
SimpleCache(
|
||||
cacheDir.resolve("media"),
|
||||
LeastRecentlyUsedCacheEvictor(cacheSize),
|
||||
exoDatabase
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val exoDownloadCache: SimpleCache by lazy {
|
||||
SimpleCache(
|
||||
cacheDir.resolve("downloads"),
|
||||
NoOpCacheEvictor(),
|
||||
exoDatabase
|
||||
)
|
||||
}
|
||||
|
||||
val exoDownloadManager: DownloadManager by lazy {
|
||||
DownloaderConstructorHelper(exoDownloadCache, QueueManager.factory(this)).run {
|
||||
DownloadManager(this@Otter, DefaultDownloadIndex(exoDatabase), DefaultDownloaderFactory(this))
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession(this)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
|
||||
|
||||
instance = this
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("night_mode")) {
|
||||
|
@ -33,4 +78,37 @@ class Otter : Application() {
|
|||
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllData() {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
|
||||
|
||||
cacheDir.listFiles()?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
cacheDir.resolve("picasso-cache").deleteRecursively()
|
||||
}
|
||||
|
||||
inner class CrashReportHandler : Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
val now = Date(Date().time - (5 * 60 * 1000))
|
||||
val formatter = SimpleDateFormat("MM-dd kk:mm:ss.000", Locale.US)
|
||||
|
||||
Runtime.getRuntime().exec(listOf("logcat", "-d", "-T", formatter.format(now)).toTypedArray()).also {
|
||||
it.inputStream.bufferedReader().also { reader ->
|
||||
val builder = StringBuilder()
|
||||
|
||||
while (true) {
|
||||
builder.appendln(reader.readLine() ?: break)
|
||||
}
|
||||
|
||||
builder.appendln(e.toString())
|
||||
|
||||
Cache.set(this@Otter, "crashdump", builder.toString().toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
defaultExceptionHandler?.uncaughtException(t, e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,33 +1,52 @@
|
|||
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.awaitObjectResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.github.kittinunf.result.Result
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.android.synthetic.main.activity_login.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class FwCredentials(val token: String)
|
||||
data class FwCredentials(val token: String, val non_field_errors: List<String>?)
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_login)
|
||||
|
||||
limitContainerWidth()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
anonymous?.setOnCheckedChangeListener { _, isChecked ->
|
||||
val state = when (isChecked) {
|
||||
true -> View.GONE
|
||||
false -> View.VISIBLE
|
||||
}
|
||||
|
||||
username_field.visibility = state
|
||||
password_field.visibility = state
|
||||
}
|
||||
|
||||
login?.setOnClickListener {
|
||||
var hostname = hostname.text.toString().trim()
|
||||
val username = username.text.toString()
|
||||
|
@ -37,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 =
|
||||
|
@ -50,9 +81,16 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
hostname_field.error = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostname_field.error = ""
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
limitContainerWidth()
|
||||
}
|
||||
|
||||
private fun authedLogin(hostname: String, username: String, password: String) {
|
||||
val body = mapOf(
|
||||
"username" to username,
|
||||
"password" to password
|
||||
|
@ -62,29 +100,46 @@ class LoginActivity : AppCompatActivity() {
|
|||
show(supportFragmentManager, "LoginDialog")
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
try {
|
||||
val result = Fuel.post("$hostname/api/v1/token/", body)
|
||||
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
|
||||
result.fold(
|
||||
{ data ->
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
||||
setString("hostname", hostname)
|
||||
setBoolean("anonymous", false)
|
||||
setString("username", username)
|
||||
setString("password", password)
|
||||
setString("access_token", data.token)
|
||||
setString("access_token", result.get().token)
|
||||
}
|
||||
|
||||
Userinfo.get()?.let {
|
||||
dialog.dismiss()
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
},
|
||||
{ error ->
|
||||
|
||||
return@launch finish()
|
||||
}
|
||||
|
||||
throw Exception(getString(R.string.login_error_userinfo))
|
||||
}
|
||||
|
||||
is Result.Failure -> {
|
||||
dialog.dismiss()
|
||||
|
||||
hostname_field.error = error.localizedMessage
|
||||
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
|
||||
|
||||
hostname_field.error = null
|
||||
username_field.error = null
|
||||
|
||||
if (error != null && error.non_field_errors?.isNotEmpty() == true) {
|
||||
username_field.error = error.non_field_errors[0]
|
||||
} else {
|
||||
hostname_field.error = result.error.localizedMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
dialog.dismiss()
|
||||
|
||||
|
@ -96,5 +151,56 @@ class LoginActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anonymousLogin(hostname: String) {
|
||||
val dialog = LoginDialog().apply {
|
||||
show(supportFragmentManager, "LoginDialog")
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
try {
|
||||
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
||||
setString("hostname", hostname)
|
||||
setBoolean("anonymous", true)
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
is Result.Failure -> {
|
||||
dialog.dismiss()
|
||||
|
||||
hostname_field.error = result.error.localizedMessage
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dialog.dismiss()
|
||||
|
||||
val message =
|
||||
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
|
||||
else e.message
|
||||
|
||||
hostname_field.error = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun limitContainerWidth() {
|
||||
container.doOnLayout {
|
||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && container.width >= 1440) {
|
||||
container.layoutParams.width = 1440
|
||||
} else {
|
||||
container.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
container.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,34 +5,49 @@ import android.animation.AnimatorListenerAdapter
|
|||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.SeekBar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.BrowseFragment
|
||||
import com.github.apognu.otter.fragments.QueueFragment
|
||||
import com.github.apognu.otter.fragments.*
|
||||
import com.github.apognu.otter.playback.MediaControlsManager
|
||||
import com.github.apognu.otter.playback.PinService
|
||||
import com.github.apognu.otter.playback.PlayerService
|
||||
import com.github.apognu.otter.repositories.FavoritedRepository
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.apognu.otter.views.DisableableFrameLayout
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.offline.DownloadService
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.partial_now_playing.*
|
||||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
enum class ResultCode(val code: Int) {
|
||||
|
@ -40,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)
|
||||
|
@ -59,16 +75,33 @@ class MainActivity : AppCompatActivity() {
|
|||
.replace(R.id.container, BrowseFragment())
|
||||
.commit()
|
||||
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
|
||||
watchEventBus()
|
||||
|
||||
CommandBus.send(Command.RefreshService)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
|
||||
if (now_playing.isOpened()) {
|
||||
now_playing.close()
|
||||
|
||||
return@setShouldRegisterTouch false
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
favoritedRepository.update(this, lifecycleScope)
|
||||
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
DownloadService.start(this, PinService::class.java)
|
||||
|
||||
CommandBus.send(Command.RefreshService)
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
Userinfo.get()
|
||||
}
|
||||
|
||||
now_playing_toggle.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
}
|
||||
|
@ -100,6 +133,10 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
landscape_queue?.let {
|
||||
supportFragmentManager.beginTransaction().replace(R.id.landscape_queue, LandscapeQueueFragment()).commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
|
@ -111,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
|
||||
}
|
||||
|
@ -135,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)
|
||||
}
|
||||
|
||||
|
@ -146,8 +252,11 @@ 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()
|
||||
}
|
||||
|
@ -177,11 +286,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
@SuppressLint("NewApi")
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.LogOut -> {
|
||||
PowerPreference.clearAllData()
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
startActivity(Intent(this@MainActivity, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
|
@ -205,6 +314,12 @@ class MainActivity : AppCompatActivity() {
|
|||
it.bottomMargin = it.bottomMargin / 2
|
||||
}
|
||||
|
||||
landscape_queue?.let { landscape_queue ->
|
||||
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin / 2
|
||||
}
|
||||
}
|
||||
|
||||
now_playing.animate()
|
||||
.alpha(0.0f)
|
||||
.setDuration(400)
|
||||
|
@ -217,75 +332,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
is Event.TrackPlayed -> {
|
||||
message.track?.let { track ->
|
||||
if (now_playing.visibility == View.GONE) {
|
||||
now_playing.visibility = View.VISIBLE
|
||||
now_playing.alpha = 0f
|
||||
|
||||
now_playing.animate()
|
||||
.alpha(1.0f)
|
||||
.setDuration(400)
|
||||
.setListener(null)
|
||||
.start()
|
||||
|
||||
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin * 2
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_title.text = track.title
|
||||
now_playing_album.text = track.artist.name
|
||||
now_playing_toggle.icon = getDrawable(R.drawable.pause)
|
||||
now_playing_progress.progress = 0
|
||||
|
||||
now_playing_details_title.text = track.title
|
||||
now_playing_details_artist.text = track.artist.name
|
||||
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
|
||||
now_playing_details_progress.progress = 0
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(now_playing_cover)
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(now_playing_details_cover)
|
||||
|
||||
favoriteCheckRepository.fetch().untilNetwork(IO) { favorites, _ ->
|
||||
GlobalScope.launch(Main) {
|
||||
track.favorite = favorites.contains(track.id)
|
||||
|
||||
when (track.favorite) {
|
||||
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_favorite.setOnClickListener {
|
||||
when (track.favorite) {
|
||||
true -> {
|
||||
favoriteRepository.deleteFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
|
||||
false -> {
|
||||
favoriteRepository.addFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
}
|
||||
}
|
||||
|
||||
track.favorite = !track.favorite
|
||||
|
||||
favoriteRepository.fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Event.TrackFinished -> incrementListenCount(message.track)
|
||||
|
||||
is Event.StateChanged -> {
|
||||
when (message.playing) {
|
||||
|
@ -314,8 +361,35 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
for ((current, duration, percent) in ProgressBus.asChannel()) {
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.StartService -> {
|
||||
Build.VERSION_CODES.O.onApi(
|
||||
{
|
||||
startForegroundService(Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
})
|
||||
},
|
||||
{
|
||||
startService(Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
|
||||
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(this@MainActivity, lifecycleScope, command.tracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
ProgressBus.get().collect { (current, duration, percent) ->
|
||||
now_playing_progress.progress = percent
|
||||
now_playing_details_progress.progress = percent
|
||||
|
||||
|
@ -330,4 +404,192 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
if (now_playing.visibility == View.GONE) {
|
||||
now_playing.visibility = View.VISIBLE
|
||||
now_playing.alpha = 0f
|
||||
|
||||
now_playing.animate()
|
||||
.alpha(1.0f)
|
||||
.setDuration(400)
|
||||
.setListener(null)
|
||||
.start()
|
||||
|
||||
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin * 2
|
||||
}
|
||||
|
||||
landscape_queue?.let { landscape_queue ->
|
||||
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_title.text = track.title
|
||||
now_playing_album.text = track.artist.name
|
||||
now_playing_toggle.icon = getDrawable(R.drawable.pause)
|
||||
|
||||
now_playing_details_title.text = track.title
|
||||
now_playing_details_artist.text = track.artist.name
|
||||
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(now_playing_cover)
|
||||
|
||||
now_playing_details_cover?.let { now_playing_details_cover ->
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(now_playing_details_cover)
|
||||
}
|
||||
|
||||
if (now_playing_details_cover == null) {
|
||||
lifecycleScope.launch(Default) {
|
||||
val width = DisplayMetrics().apply {
|
||||
windowManager.defaultDisplay.getMetrics(this)
|
||||
}.widthPixels
|
||||
|
||||
val backgroundCover = Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.get()
|
||||
.run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) }
|
||||
.apply {
|
||||
alpha = 20
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
withContext(Main) {
|
||||
now_playing_details.background = backgroundCover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_repeat?.let { now_playing_details_repeat ->
|
||||
changeRepeatMode(Cache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0)
|
||||
|
||||
now_playing_details_repeat.setOnClickListener {
|
||||
val current = Cache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0
|
||||
|
||||
changeRepeatMode((current + 1) % 3)
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_info?.let { now_playing_details_info ->
|
||||
now_playing_details_info.setOnClickListener {
|
||||
PopupMenu(this@MainActivity, now_playing_details_info, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.track_info)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_info_artist -> ArtistsFragment.openAlbums(this@MainActivity, track.artist, art = track.album?.cover())
|
||||
R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album)
|
||||
R.id.track_info_details -> TrackInfoDetailsFragment.new(track).show(supportFragmentManager, "dialog")
|
||||
}
|
||||
|
||||
now_playing.close()
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_favorite?.let { now_playing_details_favorite ->
|
||||
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
|
||||
lifecycleScope.launch(Main) {
|
||||
track.favorite = favorites.contains(track.id)
|
||||
|
||||
when (track.favorite) {
|
||||
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_favorite.setOnClickListener {
|
||||
when (track.favorite) {
|
||||
true -> {
|
||||
favoriteRepository.deleteFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
|
||||
false -> {
|
||||
favoriteRepository.addFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
}
|
||||
}
|
||||
|
||||
track.favorite = !track.favorite
|
||||
|
||||
favoriteRepository.fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
|
||||
now_playing_details_add_to_playlist.setOnClickListener {
|
||||
CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeRepeatMode(index: Int) {
|
||||
when (index) {
|
||||
// From no repeat to repeat all
|
||||
0 -> {
|
||||
Cache.set(this@MainActivity, "repeat", "0".toByteArray())
|
||||
|
||||
now_playing_details_repeat?.setImageResource(R.drawable.repeat)
|
||||
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
|
||||
now_playing_details_repeat?.alpha = 0.2f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_OFF))
|
||||
}
|
||||
|
||||
// From repeat all to repeat one
|
||||
1 -> {
|
||||
Cache.set(this@MainActivity, "repeat", "1".toByteArray())
|
||||
|
||||
now_playing_details_repeat?.setImageResource(R.drawable.repeat)
|
||||
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
|
||||
now_playing_details_repeat?.alpha = 1.0f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ALL))
|
||||
}
|
||||
|
||||
// From repeat one to no repeat
|
||||
2 -> {
|
||||
Cache.set(this@MainActivity, "repeat", "2".toByteArray())
|
||||
now_playing_details_repeat?.setImageResource(R.drawable.repeat_one)
|
||||
now_playing_details_repeat?.setColorFilter(ContextCompat.getColor(this, R.color.controlForeground))
|
||||
now_playing_details_repeat?.alpha = 1.0f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ONE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun incrementListenCount(track: Track?) {
|
||||
track?.let {
|
||||
lifecycleScope.launch(IO) {
|
||||
try {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(mapOf("track" to track.id)))
|
||||
.awaitStringResponse()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,57 +3,116 @@ package com.github.apognu.otter.activities
|
|||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.repositories.SearchRepository
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import com.github.apognu.otter.adapters.SearchAdapter
|
||||
import com.github.apognu.otter.fragments.AddToPlaylistDialog
|
||||
import com.github.apognu.otter.fragments.AlbumsFragment
|
||||
import com.github.apognu.otter.fragments.ArtistsFragment
|
||||
import com.github.apognu.otter.repositories.*
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import kotlinx.android.synthetic.main.activity_search.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
class SearchActivity : AppCompatActivity() {
|
||||
private lateinit var adapter: TracksAdapter
|
||||
private lateinit var adapter: SearchAdapter
|
||||
|
||||
lateinit var repository: SearchRepository
|
||||
lateinit var artistsRepository: ArtistsSearchRepository
|
||||
lateinit var albumsRepository: AlbumsSearchRepository
|
||||
lateinit var tracksRepository: TracksSearchRepository
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
var done = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_search)
|
||||
|
||||
adapter = TracksAdapter(this).also {
|
||||
adapter = SearchAdapter(this, SearchResultClickListener(), FavoriteListener()).also {
|
||||
results.layoutManager = LinearLayoutManager(this)
|
||||
results.adapter = it
|
||||
}
|
||||
|
||||
search.requestFocus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
search.requestFocus()
|
||||
|
||||
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
query?.let {
|
||||
repository = SearchRepository(this@SearchActivity, it.toLowerCase(Locale.ROOT))
|
||||
|
||||
search_spinner.visibility = View.VISIBLE
|
||||
search_no_results.visibility = View.GONE
|
||||
|
||||
adapter.data.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _ ->
|
||||
search_spinner.visibility = View.GONE
|
||||
search_empty.visibility = View.GONE
|
||||
|
||||
when (tracks.isEmpty()) {
|
||||
true -> search_no_results.visibility = View.VISIBLE
|
||||
false -> adapter.data = tracks.toMutableList()
|
||||
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")
|
||||
|
||||
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()
|
||||
adapter.albums.clear()
|
||||
adapter.tracks.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { artists, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.artists.addAll(artists)
|
||||
refresh()
|
||||
}
|
||||
|
||||
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { albums, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.albums.addAll(albums)
|
||||
refresh()
|
||||
}
|
||||
|
||||
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { tracks, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.tracks.addAll(tracks)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +120,52 @@ class SearchActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?) = true
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) {
|
||||
search_no_results.visibility = View.VISIBLE
|
||||
} else {
|
||||
search_no_results.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (done == 3) {
|
||||
search_spinner.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.tracks.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.tracks[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(adapter.getPositionOf(SearchAdapter.ResultType.Track, match.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
|
||||
override fun onArtistClick(holder: View?, artist: Artist) {
|
||||
ArtistsFragment.openAlbums(this@SearchActivity, artist)
|
||||
}
|
||||
|
||||
override fun onAlbumClick(holder: View?, album: Album) {
|
||||
AlbumsFragment.openTracks(this@SearchActivity, album)
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : SearchAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
package com.github.apognu.otter.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.*
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
|
@ -10,9 +10,12 @@ import androidx.preference.ListPreference
|
|||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SeekBarPreference
|
||||
import com.github.apognu.otter.BuildConfig
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.preference.PowerPreference
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.Command
|
||||
import com.github.apognu.otter.utils.CommandBus
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -59,15 +62,27 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
|
|||
}
|
||||
}
|
||||
|
||||
"crash" -> {
|
||||
activity?.let { activity ->
|
||||
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
|
||||
Cache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
|
||||
clip.setPrimaryClip(ClipData.newPlainText("Otter logs", it))
|
||||
|
||||
Toast.makeText(activity, activity.getString(R.string.settings_crash_report_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"logout" -> {
|
||||
context?.let { context ->
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(context.getString(R.string.logout_title))
|
||||
.setMessage(context.getString(R.string.logout_content))
|
||||
.setPositiveButton(android.R.string.yes) { _, _ ->
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
|
||||
context.cacheDir.deleteRecursively()
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
|
||||
activity?.finish()
|
||||
|
@ -97,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" -> {
|
||||
|
@ -125,6 +148,10 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
|
|||
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
|
||||
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<Preference>("version")?.let {
|
||||
it.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,14 +4,16 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Settings
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
|
||||
when (contains("access_token")) {
|
||||
when (Settings.hasAccessToken() || Settings.isAnonymous()) {
|
||||
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
|
||||
|
||||
|
@ -19,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)
|
||||
|
|
|
@ -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?, 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?, val listener: OnAlbumClickListener) :
|
|||
val album = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
|
||||
holder.title.text = album.title
|
||||
holder.artist.text = album.artist.name
|
||||
holder.release_date.visibility = View.GONE
|
||||
|
||||
album.release_date?.split('-')?.getOrNull(0)?.let { year ->
|
||||
if (year.isNotEmpty()) {
|
||||
holder.release_date.visibility = View.VISIBLE
|
||||
holder.release_date.text = year
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, private val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val art = view.art
|
||||
val title = view.title
|
||||
val artist = view.artist
|
||||
val release_date = view.release_date
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(view, data[layoutPosition])
|
||||
|
|
|
@ -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,10 +35,10 @@ 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(24, 0))
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = album.title
|
||||
|
|
|
@ -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,14 +50,14 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val artist = data[position]
|
||||
val artist = active[position]
|
||||
|
||||
artist.albums?.let { albums ->
|
||||
if (albums.isNotEmpty()) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(albums[0].cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +77,7 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
|
|||
val albums = view.albums
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(view, data[layoutPosition])
|
||||
listener.onClick(view, active[layoutPosition])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,21 +4,12 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.AlbumsGridFragment
|
||||
import com.github.apognu.otter.fragments.ArtistsFragment
|
||||
import com.github.apognu.otter.fragments.FavoritesFragment
|
||||
import com.github.apognu.otter.fragments.PlaylistsFragment
|
||||
import com.preference.PowerPreference
|
||||
import com.github.apognu.otter.fragments.*
|
||||
|
||||
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||
var tabs = mutableListOf<Fragment>()
|
||||
|
||||
override fun getCount(): Int {
|
||||
return when (PowerPreference.getDefaultFile().getBoolean("experiments", false)) {
|
||||
true -> 4
|
||||
false -> 3
|
||||
}
|
||||
}
|
||||
override fun getCount() = 5
|
||||
|
||||
override fun getItem(position: Int): Fragment {
|
||||
tabs.getOrNull(position)?.let {
|
||||
|
@ -29,7 +20,8 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
|
|||
0 -> ArtistsFragment()
|
||||
1 -> AlbumsGridFragment()
|
||||
2 -> PlaylistsFragment()
|
||||
3 -> FavoritesFragment()
|
||||
3 -> RadiosFragment()
|
||||
4 -> FavoritesFragment()
|
||||
else -> ArtistsFragment()
|
||||
}
|
||||
|
||||
|
@ -43,7 +35,8 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
|
|||
0 -> context.getString(R.string.artists)
|
||||
1 -> context.getString(R.string.albums)
|
||||
2 -> context.getString(R.string.playlists)
|
||||
3 -> context.getString(R.string.favorites)
|
||||
3 -> context.getString(R.string.radios)
|
||||
4 -> context.getString(R.string.favorites)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,20 +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.setTypeface(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.ripple)
|
||||
}
|
||||
|
||||
|
||||
if (favorite == currentTrack || favorite.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
if (favorite.id == currentTrack?.id) {
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
|
@ -75,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)
|
||||
|
||||
|
@ -92,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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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,12 +40,10 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
|
||||
|
@ -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,20 +67,14 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
holder.title.text = track.track.title
|
||||
holder.artist.text = track.track.artist.name
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (track.track == currentTrack || track.track.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
|
@ -100,13 +96,16 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
holder.actions.setOnClickListener {
|
||||
context?.let { context ->
|
||||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track)
|
||||
inflate(R.menu.row_track)
|
||||
|
||||
menu.findItem(R.id.track_remove_from_playlist).isVisible = true
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track))
|
||||
R.id.track_remove_from_playlist -> playlistListener?.onRemoveTrackFromPlaylist(track.track, position)
|
||||
}
|
||||
|
||||
true
|
||||
|
@ -117,7 +116,6 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
}
|
||||
}
|
||||
|
||||
if (fromQueue) {
|
||||
holder.handle.visibility = View.VISIBLE
|
||||
|
||||
holder.handle.setOnTouchListener { _, event ->
|
||||
|
@ -128,7 +126,6 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemMove(oldPosition: Int, newPosition: Int) {
|
||||
if (oldPosition < newPosition) {
|
||||
|
@ -136,13 +133,12 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
Collections.swap(data, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in newPosition.downTo(oldPosition)) {
|
||||
for (i in oldPosition.downTo(newPosition + 1)) {
|
||||
Collections.swap(data, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
notifyItemMoved(oldPosition, newPosition)
|
||||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
|
@ -155,9 +151,6 @@ 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 }))
|
||||
|
||||
|
@ -165,10 +158,11 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
|
||||
var from = -1
|
||||
var to = -1
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
|
||||
override fun isItemViewSwipeEnabled() = false
|
||||
|
@ -177,6 +171,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
if (from == -1) from = viewHolder.adapterPosition
|
||||
to = target.adapterPosition
|
||||
|
||||
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
|
||||
|
||||
return true
|
||||
|
@ -186,7 +183,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
viewHolder?.itemView?.background = ColorDrawable(Color.argb(255, 100, 100, 100))
|
||||
context?.let {
|
||||
viewHolder?.itemView?.background = ColorDrawable(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
}
|
||||
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
|
@ -195,6 +194,13 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
|
||||
|
||||
if (from != -1 && to != -1 && from != to) {
|
||||
playlistListener?.onMoveTrack(from, to)
|
||||
|
||||
from = -1
|
||||
to = -1
|
||||
}
|
||||
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,15 +4,17 @@ import android.content.Context
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.Playlist
|
||||
import com.github.apognu.otter.utils.toDurationString
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_playlist.view.*
|
||||
|
||||
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : FunkwhaleAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
|
||||
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : OtterAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
|
||||
interface OnPlaylistClickListener {
|
||||
fun onClick(holder: View?, playlist: Playlist)
|
||||
}
|
||||
|
@ -33,7 +35,16 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
|
|||
val playlist = data[position]
|
||||
|
||||
holder.name.text = playlist.name
|
||||
holder.summary.text = context?.getString(R.string.playlist_description, playlist.tracks_count, toDurationString(playlist.duration.toLong())) ?: ""
|
||||
holder.summary.text = context?.resources?.getQuantityString(R.plurals.playlist_description, playlist.tracks_count, playlist.tracks_count, toDurationString(playlist.duration.toLong())) ?: ""
|
||||
|
||||
context?.let {
|
||||
ContextCompat.getDrawable(context, R.drawable.cover).let {
|
||||
holder.cover_top_left.setImageDrawable(it)
|
||||
holder.cover_top_right.setImageDrawable(it)
|
||||
holder.cover_bottom_left.setImageDrawable(it)
|
||||
holder.cover_bottom_right.setImageDrawable(it)
|
||||
}
|
||||
}
|
||||
|
||||
playlist.album_covers.shuffled().take(4).forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
|
@ -44,8 +55,17 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
|
|||
else -> holder.cover_top_left
|
||||
}
|
||||
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.load(url)
|
||||
.transform(RoundedCornersTransformation(32, 0, corner))
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
package com.github.apognu.otter.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Event
|
||||
import com.github.apognu.otter.utils.EventBus
|
||||
import com.github.apognu.otter.utils.Radio
|
||||
import com.github.apognu.otter.views.LoadingImageView
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.android.synthetic.main.row_radio.view.*
|
||||
import kotlinx.android.synthetic.main.row_radio_header.view.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<Radio, RadiosAdapter.ViewHolder>() {
|
||||
interface OnRadioClickListener {
|
||||
fun onClick(holder: ViewHolder, radio: Radio)
|
||||
}
|
||||
|
||||
enum class RowType {
|
||||
Header,
|
||||
InstanceRadio,
|
||||
UserRadio
|
||||
}
|
||||
|
||||
private val instanceRadios: List<Radio> by lazy {
|
||||
context?.let {
|
||||
return@lazy when (val username = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
|
||||
"" -> listOf(
|
||||
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
|
||||
)
|
||||
|
||||
else -> listOf(
|
||||
Radio(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
|
||||
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
|
||||
Radio(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
|
||||
Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf<Radio>()
|
||||
}
|
||||
|
||||
private fun getRadioAt(position: Int): Radio {
|
||||
return when (getItemViewType(position)) {
|
||||
RowType.InstanceRadio.ordinal -> instanceRadios[position - 1]
|
||||
else -> data[position - instanceRadios.size - 2]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int) = when (getItemViewType(position)) {
|
||||
RowType.InstanceRadio.ordinal -> (-position - 1).toLong()
|
||||
RowType.Header.ordinal -> Long.MIN_VALUE
|
||||
else -> getRadioAt(position).id.toLong()
|
||||
}
|
||||
|
||||
override fun getItemCount() = instanceRadios.size + data.size + 2
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when {
|
||||
position == 0 || position == instanceRadios.size + 1 -> RowType.Header.ordinal
|
||||
position <= instanceRadios.size -> RowType.InstanceRadio.ordinal
|
||||
else -> RowType.UserRadio.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
|
||||
return when (viewType) {
|
||||
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
|
||||
|
||||
ViewHolder(view, listener).also {
|
||||
view.setOnClickListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
else -> ViewHolder(LayoutInflater.from(context).inflate(R.layout.row_radio_header, parent, false), null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) {
|
||||
when (getItemViewType(position)) {
|
||||
RowType.Header.ordinal -> {
|
||||
context?.let {
|
||||
when (position) {
|
||||
0 -> holder.label.text = context.getString(R.string.radio_instance_radios)
|
||||
instanceRadios.size + 1 -> holder.label.text = context.getString(R.string.radio_user_radios)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
|
||||
val radio = getRadioAt(position)
|
||||
|
||||
holder.art.visibility = View.VISIBLE
|
||||
holder.name.text = radio.name
|
||||
holder.description.text = radio.description
|
||||
|
||||
context?.let { context ->
|
||||
val icon = when (radio.radio_type) {
|
||||
"actor_content" -> R.drawable.library
|
||||
"favorites" -> R.drawable.favorite
|
||||
"random" -> R.drawable.shuffle
|
||||
"less-listened" -> R.drawable.sad
|
||||
else -> null
|
||||
}
|
||||
|
||||
icon?.let {
|
||||
holder.native = true
|
||||
|
||||
holder.art.setImageDrawable(context.getDrawable(icon))
|
||||
holder.art.alpha = 0.7f
|
||||
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, private val listener: OnRadioClickListener?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val label = view.label
|
||||
val art = view.art
|
||||
val name = view.name
|
||||
val description = view.description
|
||||
|
||||
var native = false
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener?.onClick(this, getRadioAt(layoutPosition))
|
||||
}
|
||||
|
||||
fun spin() {
|
||||
context?.let {
|
||||
val originalDrawable = art.drawable
|
||||
val originalColorFilter = art.colorFilter
|
||||
val imageAnimator = LoadingImageView.start(context, art)
|
||||
|
||||
art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
|
||||
scope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted -> {
|
||||
art.colorFilter = originalColorFilter
|
||||
|
||||
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,280 @@
|
|||
package com.github.apognu.otter.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_track.view.*
|
||||
|
||||
class SearchAdapter(private val context: Context?, private val listener: OnSearchResultClickListener? = null, private val favoriteListener: OnFavoriteListener? = null) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
|
||||
interface OnSearchResultClickListener {
|
||||
fun onArtistClick(holder: View?, artist: Artist)
|
||||
fun onAlbumClick(holder: View?, album: Album)
|
||||
}
|
||||
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
||||
enum class ResultType {
|
||||
Header,
|
||||
Artist,
|
||||
Album,
|
||||
Track
|
||||
}
|
||||
|
||||
val SECTION_COUNT = 3
|
||||
|
||||
var artists: MutableList<Artist> = mutableListOf()
|
||||
var albums: MutableList<Album> = mutableListOf()
|
||||
var tracks: MutableList<Track> = mutableListOf()
|
||||
|
||||
var currentTrack: Track? = null
|
||||
|
||||
override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return when (getItemViewType(position)) {
|
||||
ResultType.Header.ordinal -> {
|
||||
if (position == 0) return -1
|
||||
if (position == (artists.size + 1)) return -2
|
||||
return -3
|
||||
}
|
||||
|
||||
ResultType.Artist.ordinal -> artists[position].id.toLong()
|
||||
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
|
||||
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT].id.toLong()
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
if (position == 0) return ResultType.Header.ordinal // Artists header
|
||||
if (position == (artists.size + 1)) return ResultType.Header.ordinal // Albums header
|
||||
if (position == (artists.size + albums.size + 2)) return ResultType.Header.ordinal // Tracks header
|
||||
|
||||
if (position <= artists.size) return ResultType.Artist.ordinal
|
||||
if (position <= artists.size + albums.size + 2) return ResultType.Album.ordinal
|
||||
|
||||
return ResultType.Track.ordinal
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = when (viewType) {
|
||||
ResultType.Header.ordinal -> LayoutInflater.from(context).inflate(R.layout.row_search_header, parent, false)
|
||||
else -> LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
|
||||
}
|
||||
|
||||
return ViewHolder(view, context).also {
|
||||
view.setOnClickListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val resultType = getItemViewType(position)
|
||||
|
||||
if (resultType == ResultType.Header.ordinal) {
|
||||
context?.let { context ->
|
||||
if (position == 0) {
|
||||
holder.title.text = context.getString(R.string.artists)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (artists.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + 1)) {
|
||||
holder.title.text = context.getString(R.string.albums)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (albums.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + albums.size + 2)) {
|
||||
holder.title.text = context.getString(R.string.tracks)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val item = when (resultType) {
|
||||
ResultType.Artist.ordinal -> {
|
||||
holder.actions.visibility = View.GONE
|
||||
holder.favorite.visibility = View.GONE
|
||||
|
||||
artists[position - 1]
|
||||
}
|
||||
|
||||
ResultType.Album.ordinal -> {
|
||||
holder.actions.visibility = View.GONE
|
||||
holder.favorite.visibility = View.GONE
|
||||
|
||||
albums[position - artists.size - 2]
|
||||
}
|
||||
|
||||
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT]
|
||||
|
||||
else -> tracks[position]
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(item.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = item.title()
|
||||
holder.artist.text = item.subtitle()
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
|
||||
holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
|
||||
if (resultType == ResultType.Track.ordinal) {
|
||||
(item as? Track)?.let { track ->
|
||||
context?.let { context ->
|
||||
if (track == currentTrack || track.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
}
|
||||
|
||||
when (track.favorite) {
|
||||
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener?.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
tracks[position - artists.size - albums.size - SECTION_COUNT].favorite = !track.favorite
|
||||
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
holder.actions.setOnClickListener {
|
||||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.row_track)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
|
||||
R.id.track_add_to_playlist -> CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPositionOf(type: ResultType, position: Int): Int {
|
||||
return when (type) {
|
||||
ResultType.Artist -> position + 1
|
||||
ResultType.Album -> position + artists.size + 2
|
||||
ResultType.Track -> artists.size + albums.size + SECTION_COUNT + position
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val handle = view.handle
|
||||
val cover = view.cover
|
||||
val title = view.title
|
||||
val artist = view.artist
|
||||
|
||||
val favorite = view.favorite
|
||||
val actions = view.actions
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
when (getItemViewType(layoutPosition)) {
|
||||
ResultType.Artist.ordinal -> {
|
||||
val position = layoutPosition - 1
|
||||
|
||||
listener?.onArtistClick(view, artists[position])
|
||||
}
|
||||
|
||||
ResultType.Album.ordinal -> {
|
||||
val position = layoutPosition - artists.size - 2
|
||||
|
||||
listener?.onAlbumClick(view, albums[position])
|
||||
}
|
||||
|
||||
ResultType.Track.ordinal -> {
|
||||
val position = layoutPosition - artists.size - albums.size - SECTION_COUNT
|
||||
|
||||
tracks.subList(position, tracks.size).plus(tracks.subList(0, position)).apply {
|
||||
CommandBus.send(Command.ReplaceQueue(this))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,44 +53,56 @@ 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(16, 0))
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = track.title
|
||||
holder.artist.text = track.artist.name
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (track == currentTrack || track.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
when (track.favorite) {
|
||||
true -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorFavorite))
|
||||
false -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorSelected))
|
||||
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener?.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
track.favorite = !track.favorite
|
||||
data[position].favorite = !track.favorite
|
||||
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
holder.actions.setOnClickListener {
|
||||
|
@ -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,13 +146,12 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
Collections.swap(data, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in newPosition.downTo(oldPosition)) {
|
||||
for (i in oldPosition.downTo(newPosition + 1)) {
|
||||
Collections.swap(data, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
notifyItemMoved(oldPosition, newPosition)
|
||||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
|
@ -168,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
|
||||
|
@ -176,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
|
||||
}
|
||||
|
@ -186,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -194,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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,38 +1,94 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.adapters.AlbumsAdapter
|
||||
import com.github.apognu.otter.repositories.AlbumsRepository
|
||||
import com.github.apognu.otter.repositories.ArtistTracksRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_albums.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
||||
class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums
|
||||
override val recycler: RecyclerView get() = albums
|
||||
override val alwaysRefresh = false
|
||||
|
||||
private lateinit var artistTracksRepository: ArtistTracksRepository
|
||||
|
||||
var artistId = 0
|
||||
var artistName = ""
|
||||
var artistArt = ""
|
||||
|
||||
companion object {
|
||||
fun new(artist: Artist): AlbumsFragment {
|
||||
fun new(artist: Artist, _art: String? = null): AlbumsFragment {
|
||||
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else ""
|
||||
|
||||
return AlbumsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"artistId" to artist.id,
|
||||
"artistName" to artist.name,
|
||||
"artistArt" to if (artist.albums?.isNotEmpty() == true) artist.albums[0].cover.original else ""
|
||||
"artistArt" to art
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) {
|
||||
if (album == null) {
|
||||
return
|
||||
}
|
||||
|
||||
(context as? MainActivity)?.let {
|
||||
fragment?.let { fragment ->
|
||||
fragment.onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(context as? AppCompatActivity)?.let { activity ->
|
||||
val nextFragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, nextFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -46,46 +102,74 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
|||
|
||||
adapter = AlbumsAdapter(context, OnAlbumClickListener())
|
||||
repository = AlbumsRepository(context, artistId)
|
||||
artistTracksRepository = ArtistTracksRepository(context, artistId)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
cover?.let { cover ->
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(artistArt))
|
||||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(cover)
|
||||
}
|
||||
|
||||
artist.text = artistName
|
||||
|
||||
play.setOnClickListener {
|
||||
val loader = CircularProgressDrawable(requireContext()).apply {
|
||||
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
loader.start()
|
||||
|
||||
play.icon = loader
|
||||
play.isClickable = false
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
artistTracksRepository.fetch(Repository.Origin.Network.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
.shuffled()
|
||||
.also {
|
||||
CommandBus.send(Command.ReplaceQueue(it))
|
||||
|
||||
withContext(Main) {
|
||||
play.icon = requireContext().getDrawable(R.drawable.play)
|
||||
play.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = cover.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
cover.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
cover.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
|
||||
override fun onClick(view: View?, album: Album) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
openTracks(context, album, fragment = this@AlbumsFragment)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,13 +13,13 @@ import com.github.apognu.otter.adapters.AlbumsGridAdapter
|
|||
import com.github.apognu.otter.repositories.AlbumsRepository
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.onViewPager
|
||||
import kotlinx.android.synthetic.main.fragment_albums_grid.*
|
||||
|
||||
class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
|
||||
class AlbumsGridFragment : OtterFragment<Album, AlbumsGridAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums_grid
|
||||
override val recycler: RecyclerView get() = albums
|
||||
override val layoutManager get() = GridLayoutManager(context, 3)
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -31,7 +31,6 @@ class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
|
|||
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
|
||||
override fun onClick(view: View?, album: Album) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
@ -40,7 +39,6 @@ class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
|
|||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
|
@ -15,9 +18,44 @@ import com.github.apognu.otter.utils.Artist
|
|||
import com.github.apognu.otter.utils.onViewPager
|
||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||
|
||||
class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
|
||||
class ArtistsFragment : OtterFragment<Artist, ArtistsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_artists
|
||||
override val recycler: RecyclerView get() = artists
|
||||
override val alwaysRefresh = false
|
||||
|
||||
companion object {
|
||||
fun openAlbums(context: Context?, artist: Artist, fragment: Fragment? = null, art: String? = null) {
|
||||
(context as? MainActivity)?.let {
|
||||
fragment?.let { fragment ->
|
||||
fragment.onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(context as? AppCompatActivity)?.let { activity ->
|
||||
val nextFragment = AlbumsFragment.new(artist, art).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, nextFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -28,31 +66,7 @@ class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
|
|||
|
||||
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
|
||||
override fun onClick(holder: View?, artist: Artist) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = AlbumsFragment.new(artist).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
openAlbums(context, artist, fragment = this@ArtistsFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +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
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -29,42 +33,84 @@ 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 ->
|
||||
withContext(Main) {
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
refreshDownloadedTracks()
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTracks() {
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Main) {
|
||||
adapter.data[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack?.current = false
|
||||
adapter.currentTrack = track.apply {
|
||||
current = true
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : FavoritesAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
(repository as? FavoritesRepository)?.let { repository ->
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
true -> repository.addFavorite(id)
|
||||
false -> repository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,106 +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.canScrollVertically(1)) {
|
||||
fetch(Repository.Origin.Network.origin, adapter.data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetch()
|
||||
}
|
||||
|
||||
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 and Repository.Origin.Cache.origin), size: Int = 0) {
|
||||
var cleared = false
|
||||
|
||||
swiper?.isRefreshing = true
|
||||
|
||||
if (size == 0) {
|
||||
cleared = true
|
||||
adapter.data.clear()
|
||||
}
|
||||
|
||||
repository.fetch(upstreams, size).untilNetwork(IO) { data, hasMore ->
|
||||
onDataFetched(data)
|
||||
|
||||
if (!hasMore) {
|
||||
swiper?.isRefreshing = false
|
||||
|
||||
repository.cacheId?.let { cacheId ->
|
||||
Cache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(repository.cache(adapter.data)).toByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
adapter.data.addAll(data)
|
||||
|
||||
when (cleared) {
|
||||
true -> {
|
||||
adapter.notifyDataSetChanged()
|
||||
cleared = false
|
||||
}
|
||||
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.view.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LandscapeQueueFragment : Fragment() {
|
||||
private var adapter: TracksAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.partial_queue, container, false).apply {
|
||||
adapter = TracksAdapter(context, fromQueue = true).also {
|
||||
queue.layoutManager = LinearLayoutManager(context)
|
||||
queue.adapter = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
|
||||
queue_shuffle.setOnClickListener {
|
||||
CommandBus.send(Command.ShuffleQueue)
|
||||
}
|
||||
|
||||
queue_save.setOnClickListener {
|
||||
adapter?.data?.let {
|
||||
CommandBus.send(Command.AddToPlaylist(it))
|
||||
}
|
||||
}
|
||||
|
||||
queue_clear.setOnClickListener {
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
it.notifyDataSetChanged()
|
||||
|
||||
if (it.data.isEmpty()) {
|
||||
queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
} else {
|
||||
queue?.visibility = View.VISIBLE
|
||||
placeholder?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,25 +1,32 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.PlaylistTracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.ManagementPlaylistsRepository
|
||||
import com.github.apognu.otter.repositories.PlaylistTracksRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
lateinit var playlistsRepository: ManagementPlaylistsRepository
|
||||
|
||||
var albumId = 0
|
||||
var albumArtist = ""
|
||||
|
@ -49,9 +56,10 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
adapter = PlaylistTracksAdapter(context, FavoriteListener())
|
||||
adapter = PlaylistTracksAdapter(context, FavoriteListener(), PlaylistListener())
|
||||
repository = PlaylistTracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
playlistsRepository = ManagementPlaylistsRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
@ -69,28 +77,60 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = covers.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
covers.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
covers.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
context?.let { context ->
|
||||
actions.setOnClickListener {
|
||||
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.album)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.add_to_queue -> {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data.map { it.track }))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>) {
|
||||
data.map { it.track.album }.toSet().map { it.cover.original }.take(4).forEachIndexed { index, url ->
|
||||
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> cover_top_left
|
||||
1 -> cover_top_right
|
||||
|
@ -99,30 +139,43 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
else -> cover_top_left
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
imageView?.let { view ->
|
||||
lifecycleScope.launch(Main) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(url))
|
||||
.into(imageView)
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0, corner))
|
||||
.into(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack = track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : PlaylistTracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
|
@ -132,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -6,24 +6,32 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.android.synthetic.main.fragment_queue.*
|
||||
import kotlinx.android.synthetic.main.fragment_queue.view.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.view.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QueueFragment : BottomSheetDialogFragment() {
|
||||
private var adapter: TracksAdapter? = null
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
|
||||
setStyle(DialogFragment.STYLE_NORMAL, R.style.AppTheme_FloatingBottomSheet)
|
||||
|
||||
watchEventBus()
|
||||
|
@ -41,9 +49,9 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_queue, container, false).apply {
|
||||
adapter = TracksAdapter(context, fromQueue = true).also {
|
||||
queue.layoutManager = LinearLayoutManager(context)
|
||||
queue.adapter = it
|
||||
adapter = TracksAdapter(context, FavoriteListener(), fromQueue = true).also {
|
||||
included.queue.layoutManager = LinearLayoutManager(context)
|
||||
included.queue.adapter = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -51,39 +59,71 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
queue?.visibility = View.GONE
|
||||
included.queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
|
||||
queue_shuffle.setOnClickListener {
|
||||
CommandBus.send(Command.ShuffleQueue)
|
||||
}
|
||||
|
||||
queue_save.setOnClickListener {
|
||||
adapter?.data?.let {
|
||||
CommandBus.send(Command.AddToPlaylist(it))
|
||||
}
|
||||
}
|
||||
|
||||
queue_clear.setOnClickListener {
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
included?.let { included ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
it.notifyDataSetChanged()
|
||||
|
||||
if (it.data.isEmpty()) {
|
||||
queue?.visibility = View.GONE
|
||||
included.queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
} else {
|
||||
queue?.visibility = View.VISIBLE
|
||||
included.queue?.visibility = View.VISIBLE
|
||||
placeholder?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refresh()
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.RadiosAdapter
|
||||
import com.github.apognu.otter.repositories.RadiosRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import kotlinx.android.synthetic.main.fragment_radios.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadiosFragment : OtterFragment<Radio, RadiosAdapter>() {
|
||||
override val viewRes = R.layout.fragment_radios
|
||||
override val recycler: RecyclerView get() = radios
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = RadiosAdapter(context, lifecycleScope, RadioClickListener())
|
||||
repository = RadiosRepository(context)
|
||||
}
|
||||
|
||||
inner class RadioClickListener : RadiosAdapter.OnRadioClickListener {
|
||||
override fun onClick(holder: RadiosAdapter.ViewHolder, radio: Radio) {
|
||||
holder.spin()
|
||||
recycler.forEach {
|
||||
it.isEnabled = false
|
||||
it.isClickable = false
|
||||
}
|
||||
|
||||
CommandBus.send(Command.PlayRadio(radio))
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted ->
|
||||
if (radios != null) {
|
||||
recycler.forEach {
|
||||
it.isEnabled = true
|
||||
it.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.widget.TextView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.mustNormalizeUrl
|
||||
import com.github.apognu.otter.utils.toDurationString
|
||||
import kotlinx.android.synthetic.main.fragment_track_info_details.*
|
||||
|
||||
class TrackInfoDetailsFragment : DialogFragment() {
|
||||
companion object {
|
||||
fun new(track: Track): TrackInfoDetailsFragment {
|
||||
return TrackInfoDetailsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"artistName" to track.artist.name,
|
||||
"albumTitle" to track.album?.title,
|
||||
"trackTitle" to track.title,
|
||||
"trackCopyright" to track.copyright,
|
||||
"trackLicense" to track.license,
|
||||
"trackPosition" to track.position,
|
||||
"trackDuration" to track.bestUpload()?.duration?.toLong()?.let { toDurationString(it, showSeconds = true) },
|
||||
"trackBitrate" to track.bestUpload()?.bitrate?.let { "${it / 1000} Kbps" },
|
||||
"trackInstance" to track.bestUpload()?.listen_url?.let { Uri.parse(mustNormalizeUrl(it)).authority }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var properties: MutableList<Pair<Int, String?>> = mutableListOf()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
properties.add(Pair(R.string.track_info_details_artist, getString("artistName")))
|
||||
properties.add(Pair(R.string.track_info_details_album, getString("albumTitle")))
|
||||
properties.add(Pair(R.string.track_info_details_track_title, getString("trackTitle")))
|
||||
properties.add(Pair(R.string.track_info_details_track_copyright, getString("trackCopyright")))
|
||||
properties.add(Pair(R.string.track_info_details_track_license, getString("trackLicense")))
|
||||
properties.add(Pair(R.string.track_info_details_track_duration, getString("trackDuration")))
|
||||
properties.add(Pair(R.string.track_info_details_track_position, getInt("trackPosition").toString()))
|
||||
properties.add(Pair(R.string.track_info_details_track_bitrate, getString("trackBitrate")))
|
||||
properties.add(Pair(R.string.track_info_details_track_instance, getString("trackInstance")))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_track_info_details, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
properties.forEach { (label, value) ->
|
||||
val labelTextView = TextView(context).apply {
|
||||
text = getString(label)
|
||||
setTextAppearance(R.style.AppTheme_TrackDetailsLabel)
|
||||
}
|
||||
|
||||
val valueTextView = TextView(context).apply {
|
||||
text = value ?: "N/A"
|
||||
setTextAppearance(R.style.AppTheme_TrackDetailsValue)
|
||||
setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f, resources.displayMetrics).toInt())
|
||||
}
|
||||
|
||||
infos.addView(labelTextView)
|
||||
infos.addView(valueTextView)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +1,36 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritedRepository
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.TracksRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
||||
class TracksFragment : OtterFragment<Track, TracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
lateinit var favoritedRepository: FavoritedRepository
|
||||
|
||||
private var albumId = 0
|
||||
private var albumArtist = ""
|
||||
|
@ -33,7 +44,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
"albumId" to album.id,
|
||||
"albumArtist" to album.artist.name,
|
||||
"albumTitle" to album.title,
|
||||
"albumCover" to album.cover.original
|
||||
"albumCover" to album.cover()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +63,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
adapter = TracksAdapter(context, FavoriteListener())
|
||||
repository = TracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
@ -64,6 +76,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(cover)
|
||||
|
||||
artist.text = albumArtist
|
||||
|
@ -73,43 +86,138 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
refreshDownloadedTracks()
|
||||
}
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = cover.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
cover.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
cover.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> play.text = getString(R.string.playback_play)
|
||||
else -> play.text = getString(R.string.playback_shuffle)
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
}
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data))
|
||||
context?.let { context ->
|
||||
actions.setOnClickListener {
|
||||
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.album)
|
||||
|
||||
menu.findItem(R.id.play_secondary)?.let { item ->
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> item.title = getString(R.string.playback_shuffle)
|
||||
else -> item.title = getString(R.string.playback_play)
|
||||
}
|
||||
}
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.play_secondary -> when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
}
|
||||
|
||||
R.id.add_to_queue -> {
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.AddToQueue(adapter.data))
|
||||
else -> CommandBus.send(Command.AddToQueue(adapter.data.shuffled()))
|
||||
}
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
lifecycleScope.launch(IO) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTracks() {
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Main) {
|
||||
adapter.data[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack?.current = false
|
||||
adapter.currentTrack = track.apply {
|
||||
current = true
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
|
|
|
@ -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,32 +37,35 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
|
|||
false -> R.drawable.play
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(Default) {
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
|
||||
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
|
||||
|
||||
mediaSession.setMetadata(MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadata.METADATA_KEY_ARTIST, track.artist.name)
|
||||
putString(MediaMetadata.METADATA_KEY_TITLE, track.title)
|
||||
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
|
||||
}.build())
|
||||
val coverUrl = maybeNormalizeUrl(track.album?.cover())
|
||||
|
||||
notification = NotificationCompat.Builder(
|
||||
context,
|
||||
AppContext.NOTIFICATION_CHANNEL_MEDIA_CONTROL
|
||||
)
|
||||
.setShowWhen(false)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setStyle(
|
||||
MediaStyle()
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ottericon)
|
||||
.setSmallIcon(R.drawable.ottershape)
|
||||
.run {
|
||||
val url = maybeNormalizeUrl(track.album.cover.original)
|
||||
coverUrl?.let {
|
||||
try {
|
||||
setLargeIcon(Picasso.get().load(coverUrl).get())
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
if (url != null) setLargeIcon(Picasso.get().load(url).get())
|
||||
else this
|
||||
return@run this
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
.setContentTitle(track.title)
|
||||
.setContentText(track.artist.name)
|
||||
|
@ -73,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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,54 +8,88 @@ import android.content.IntentFilter
|
|||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class PlayerService : Service() {
|
||||
private lateinit var queue: QueueManager
|
||||
private val jobs = mutableListOf<Job>()
|
||||
companion object {
|
||||
const val INITIAL_COMMAND_KEY = "start_command"
|
||||
}
|
||||
|
||||
private var started = false
|
||||
private val scope: CoroutineScope = CoroutineScope(Job() + Main)
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioFocusChangeListener = AudioFocusChange()
|
||||
private var stateWhenLostFocus = false
|
||||
|
||||
private lateinit var queue: QueueManager
|
||||
private lateinit var mediaControlsManager: MediaControlsManager
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var player: SimpleExoPlayer
|
||||
|
||||
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
|
||||
|
||||
private lateinit var playerEventListener: PlayerEventListener
|
||||
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
|
||||
|
||||
private var progressCache = Triple(0, 0, 0)
|
||||
|
||||
private lateinit var radioPlayer: RadioPlayer
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.action?.let {
|
||||
if (it == Intent.ACTION_MEDIA_BUTTON) {
|
||||
intent.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
|
||||
when (key.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
if (hasAudioFocus(true)) MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
|
||||
Unit
|
||||
}
|
||||
else -> MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
started = true
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
queue = QueueManager(this)
|
||||
radioPlayer = RadioPlayer(this, scope)
|
||||
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
|
||||
setAudioAttributes(AudioAttributes.Builder().run {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
|
@ -71,155 +105,140 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply {
|
||||
isActive = true
|
||||
}
|
||||
mediaControlsManager = MediaControlsManager(this, scope, Otter.get().mediaSession.session)
|
||||
|
||||
mediaControlsManager = MediaControlsManager(this, mediaSession)
|
||||
|
||||
player = ExoPlayerFactory.newSimpleInstance(this).apply {
|
||||
player = SimpleExoPlayer.Builder(this).build().apply {
|
||||
playWhenReady = false
|
||||
|
||||
playerEventListener = PlayerEventListener().also {
|
||||
addListener(it)
|
||||
}
|
||||
|
||||
MediaSessionConnector(mediaSession).also {
|
||||
it.setPlayer(this)
|
||||
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
|
||||
mediaButtonEvent?.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
|
||||
if (key.action == KeyEvent.ACTION_UP) {
|
||||
when (key.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Otter.get().mediaSession.active = true
|
||||
|
||||
Otter.get().mediaSession.connector.apply {
|
||||
setPlayer(player)
|
||||
|
||||
setMediaMetadataProvider {
|
||||
buildTrackMetadata(queue.current())
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.current > -1) {
|
||||
player.prepare(queue.datasources, true, true)
|
||||
player.seekTo(queue.current, 0)
|
||||
player.prepare(queue.datasources)
|
||||
|
||||
Cache.get(this, "progress")?.let { progress ->
|
||||
player.seekTo(queue.current, progress.readLine().toLong())
|
||||
|
||||
val (current, duration, percent) = getProgress(true)
|
||||
|
||||
ProgressBus.send(current, duration, percent)
|
||||
}
|
||||
}
|
||||
|
||||
registerReceiver(headphonesUnpluggedReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
for (message in CommandBus.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.TrackPlayed(
|
||||
queue.current(),
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
EventBus.send(
|
||||
Event.StateChanged(
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
EventBus.send(Event.StateChanged(player.playWhenReady))
|
||||
}
|
||||
}
|
||||
|
||||
is Command.ReplaceQueue -> {
|
||||
queue.replace(message.queue)
|
||||
if (!command.fromRadio) radioPlayer.stop()
|
||||
|
||||
queue.replace(command.queue)
|
||||
player.prepare(queue.datasources, true, true)
|
||||
|
||||
state(true)
|
||||
setPlaybackState(true)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.AddToQueue -> queue.append(message.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(message.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(message.track)
|
||||
is Command.MoveFromQueue -> queue.move(message.oldPosition, message.newPosition)
|
||||
is Command.AddToQueue -> queue.append(command.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(command.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(command.track)
|
||||
is Command.MoveFromQueue -> queue.move(command.oldPosition, command.newPosition)
|
||||
|
||||
is Command.PlayTrack -> {
|
||||
queue.current = message.index
|
||||
player.seekTo(message.index, C.TIME_UNSET)
|
||||
queue.current = command.index
|
||||
player.seekTo(command.index, C.TIME_UNSET)
|
||||
|
||||
state(true)
|
||||
setPlaybackState(true)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.ToggleState -> toggle()
|
||||
is Command.SetState -> state(message.state)
|
||||
is Command.ToggleState -> togglePlayback()
|
||||
is Command.SetState -> setPlaybackState(command.state)
|
||||
|
||||
is Command.NextTrack -> player.next()
|
||||
is Command.PreviousTrack -> previousTrack()
|
||||
is Command.Seek -> progress(message.progress)
|
||||
is Command.NextTrack -> skipToNextTrack()
|
||||
is Command.PreviousTrack -> skipToPreviousTrack()
|
||||
is Command.Seek -> seek(command.progress)
|
||||
|
||||
is Command.ClearQueue -> {
|
||||
queue.clear()
|
||||
player.stop()
|
||||
}
|
||||
is Command.ShuffleQueue -> queue.shuffle()
|
||||
|
||||
is Command.PlayRadio -> {
|
||||
queue.clear()
|
||||
radioPlayer.play(command.radio)
|
||||
}
|
||||
|
||||
if (player.playWhenReady) {
|
||||
mediaControlsManager.tick()
|
||||
}
|
||||
}
|
||||
})
|
||||
is Command.SetRepeatMode -> player.repeatMode = command.mode
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
for (request in RequestBus.asChannel<Request>()) {
|
||||
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
|
||||
is Command.PinTracks -> command.tracks.forEach { PinService.download(this@PlayerService, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Main) {
|
||||
RequestBus.get().collect { request ->
|
||||
when (request) {
|
||||
is Request.GetCurrentTrack -> request.channel?.offer(
|
||||
Response.CurrentTrack(
|
||||
queue.current()
|
||||
)
|
||||
)
|
||||
is Request.GetState -> request.channel?.offer(
|
||||
Response.State(
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
is Request.GetQueue -> request.channel?.offer(
|
||||
Response.Queue(
|
||||
queue.get()
|
||||
)
|
||||
)
|
||||
is Request.GetCurrentTrack -> request.channel?.offer(Response.CurrentTrack(queue.current()))
|
||||
is Request.GetState -> request.channel?.offer(Response.State(player.playWhenReady))
|
||||
is Request.GetQueue -> request.channel?.offer(Response.Queue(queue.get()))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
scope.launch(Main) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
|
||||
val (current, duration, percent) = progress()
|
||||
val (current, duration, percent) = getProgress()
|
||||
|
||||
if (player.playWhenReady) {
|
||||
ProgressBus.send(current, duration, percent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
|
||||
if (!player.playWhenReady) {
|
||||
NotificationManagerCompat.from(this).cancelAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onDestroy() {
|
||||
jobs.forEach { it.cancel() }
|
||||
scope.cancel()
|
||||
|
||||
try {
|
||||
unregisterReceiver(headphonesUnpluggedReceiver)
|
||||
|
@ -237,27 +256,96 @@ class PlayerService : Service() {
|
|||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
})
|
||||
|
||||
mediaSession.isActive = false
|
||||
mediaSession.release()
|
||||
|
||||
player.removeListener(playerEventListener)
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
player.release()
|
||||
|
||||
queue.cache.release()
|
||||
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
Otter.get().mediaSession.active = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun state(state: Boolean) {
|
||||
private fun setPlaybackState(state: Boolean) {
|
||||
if (!state) {
|
||||
val (progress, _, _) = getProgress()
|
||||
|
||||
Cache.set(this@PlayerService, "progress", progress.toString().toByteArray())
|
||||
}
|
||||
|
||||
if (state && player.playbackState == Player.STATE_IDLE) {
|
||||
player.prepare(queue.datasources)
|
||||
}
|
||||
|
||||
if (hasAudioFocus(state)) {
|
||||
player.playWhenReady = state
|
||||
|
||||
EventBus.send(Event.StateChanged(state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
setPlaybackState(!player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun skipToPreviousTrack() {
|
||||
if (player.currentPosition > 5000) {
|
||||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
}
|
||||
|
||||
private fun skipToNextTrack() {
|
||||
player.next()
|
||||
|
||||
Cache.set(this@PlayerService, "progress", "0".toByteArray())
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun getProgress(force: Boolean = false): Triple<Int, Int, Int> {
|
||||
if (!player.playWhenReady && !force) return progressCache
|
||||
|
||||
return queue.current()?.bestUpload()?.let { upload ->
|
||||
val current = player.currentPosition
|
||||
val duration = upload.duration.toFloat()
|
||||
val percent = ((current / (duration * 1000)) * 100).toInt()
|
||||
|
||||
progressCache = Triple(current.toInt(), duration.toInt(), percent)
|
||||
progressCache
|
||||
} ?: Triple(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun seek(value: Int) {
|
||||
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
|
||||
|
||||
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
|
||||
|
||||
player.seekTo(duration.toLong())
|
||||
}
|
||||
|
||||
private fun buildTrackMetadata(track: Track?): MediaMetadataCompat {
|
||||
track?.let {
|
||||
val coverUrl = maybeNormalizeUrl(track.album?.cover())
|
||||
|
||||
return mediaMetadataBuilder.apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
|
||||
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
|
||||
|
||||
try {
|
||||
runBlocking(IO) {
|
||||
this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
return mediaMetadataBuilder.build()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun hasAudioFocus(state: Boolean): Boolean {
|
||||
var allowed = !state
|
||||
|
||||
if (!allowed) {
|
||||
|
@ -283,133 +371,100 @@ class PlayerService : Service() {
|
|||
)
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
player.playWhenReady = state
|
||||
|
||||
EventBus.send(Event.StateChanged(state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
state(!player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun previousTrack() {
|
||||
if (player.currentPosition > 5000) {
|
||||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
}
|
||||
|
||||
private fun progress(): Triple<Int, Int, Int> {
|
||||
if (!player.playWhenReady) return progressCache
|
||||
|
||||
return queue.current()?.bestUpload()?.let { upload ->
|
||||
val current = player.currentPosition
|
||||
val duration = upload.duration.toFloat()
|
||||
val percent = ((current / (duration * 1000)) * 100).toInt()
|
||||
|
||||
progressCache = Triple(current.toInt(), duration.toInt(), percent)
|
||||
progressCache
|
||||
} ?: Triple(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun progress(value: Int) {
|
||||
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
|
||||
|
||||
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
|
||||
|
||||
player.seekTo(duration.toLong())
|
||||
return allowed
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
inner class PlayerEventListener : Player.EventListener {
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
super.onPlayerStateChanged(playWhenReady, playbackState)
|
||||
|
||||
EventBus.send(
|
||||
Event.StateChanged(
|
||||
playWhenReady
|
||||
)
|
||||
)
|
||||
EventBus.send(Event.StateChanged(playWhenReady))
|
||||
|
||||
if (queue.current == -1) {
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
playWhenReady
|
||||
)
|
||||
)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
when (playWhenReady) {
|
||||
true -> {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
|
||||
Player.STATE_BUFFERING -> EventBus.send(
|
||||
Event.Buffering(
|
||||
true
|
||||
)
|
||||
)
|
||||
Player.STATE_IDLE -> state(false)
|
||||
Player.STATE_ENDED -> EventBus.send(Event.PlaybackStopped)
|
||||
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
|
||||
Player.STATE_ENDED -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
queue.current = 0
|
||||
player.seekTo(0, C.TIME_UNSET)
|
||||
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
if (playbackState != Player.STATE_BUFFERING) EventBus.send(
|
||||
Event.Buffering(
|
||||
false
|
||||
)
|
||||
)
|
||||
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
|
||||
)
|
||||
EventBus.send(Event.Buffering(false))
|
||||
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
mediaControlsManager.updateNotification(queue.current(), false)
|
||||
stopForeground(false)
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
|
||||
Player.STATE_IDLE -> mediaControlsManager.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {
|
||||
override fun onTracksChanged(trackGroups: TrackGroupArray, trackSelections: TrackSelectionArray) {
|
||||
super.onTracksChanged(trackGroups, trackSelections)
|
||||
|
||||
if (queue.current != player.currentWindowIndex) {
|
||||
queue.current = player.currentWindowIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
|
||||
|
||||
Cache.set(
|
||||
this@PlayerService,
|
||||
"current",
|
||||
queue.current.toString().toByteArray()
|
||||
)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: ExoPlaybackException?) {
|
||||
EventBus.send(
|
||||
Event.PlaybackError(
|
||||
getString(R.string.error_playback)
|
||||
)
|
||||
)
|
||||
if (queue.get().isNotEmpty() && queue.current() == queue.get().last() && radioPlayer.isActive()) {
|
||||
scope.launch(IO) {
|
||||
if (radioPlayer.lock.tryAcquire()) {
|
||||
radioPlayer.prepareNextTrack()
|
||||
radioPlayer.lock.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.next()
|
||||
Cache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(reason: Int) {
|
||||
super.onPositionDiscontinuity(reason)
|
||||
|
||||
if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
|
||||
EventBus.send(Event.TrackFinished(queue.current()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: ExoPlaybackException) {
|
||||
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
|
||||
|
||||
if (player.playWhenReady) {
|
||||
queue.current++
|
||||
player.prepare(queue.datasources, true, true)
|
||||
player.seekTo(queue.current, 0)
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -419,18 +474,18 @@ class PlayerService : Service() {
|
|||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
player.volume = 1f
|
||||
|
||||
state(stateWhenLostFocus)
|
||||
setPlaybackState(stateWhenLostFocus)
|
||||
stateWhenLostFocus = false
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
stateWhenLostFocus = false
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
stateWhenLostFocus = player.playWhenReady
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
|
|
|
@ -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 token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
||||
defaultRequestProperties.apply {
|
||||
set("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
return CacheDataSourceFactory(cache, http)
|
||||
}
|
||||
|
||||
fun replace(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
val factory = factory(context)
|
||||
|
||||
val sources = tracks.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
@ -87,10 +90,10 @@ class QueueManager(val context: Context) {
|
|||
}
|
||||
|
||||
fun append(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
val tracks = tracks.filter { metadata.indexOf(it) == -1 }
|
||||
val factory = factory(context)
|
||||
val missingTracks = tracks.filter { metadata.indexOf(it) == -1 }
|
||||
|
||||
val sources = tracks.map { track ->
|
||||
val sources = missingTracks.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
|
||||
|
@ -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()
|
||||
|
@ -154,4 +173,39 @@ class QueueManager(val context: Context) {
|
|||
|
||||
return metadata.getOrNull(current)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
metadata = mutableListOf()
|
||||
datasources.clear()
|
||||
current = -1
|
||||
|
||||
persist()
|
||||
}
|
||||
|
||||
fun shuffle() {
|
||||
if (metadata.size < 2) return
|
||||
|
||||
if (current == -1) {
|
||||
replace(metadata.shuffled())
|
||||
} else {
|
||||
move(current, 0)
|
||||
current = 0
|
||||
|
||||
val shuffled =
|
||||
metadata
|
||||
.drop(1)
|
||||
.shuffled()
|
||||
|
||||
while (metadata.size > 1) {
|
||||
datasources.removeMediaSource(metadata.size - 1)
|
||||
metadata.removeAt(metadata.size - 1)
|
||||
}
|
||||
|
||||
append(shuffled)
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
package com.github.apognu.otter.playback
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.repositories.FavoritedRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null, var related_object_id: String? = null)
|
||||
data class RadioSession(val id: Int)
|
||||
data class RadioTrackBody(val session: Int)
|
||||
data class RadioTrack(val position: Int, val track: RadioTrackID)
|
||||
data class RadioTrackID(val id: Int)
|
||||
|
||||
class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
||||
val lock = Semaphore(1)
|
||||
|
||||
private var currentRadio: Radio? = null
|
||||
private var session: Int? = null
|
||||
private var cookie: String? = null
|
||||
|
||||
private val favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
init {
|
||||
Cache.get(context, "radio_type")?.readLine()?.let { radio_type ->
|
||||
Cache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
|
||||
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
|
||||
val cachedCookie = Cache.get(context, "radio_cookie")?.readLine()
|
||||
|
||||
currentRadio = Radio(radio_id, radio_type, "", "")
|
||||
session = radio_session
|
||||
cookie = cachedCookie
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(radio: Radio) {
|
||||
currentRadio = radio
|
||||
session = null
|
||||
|
||||
scope.launch(IO) {
|
||||
createSession()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
currentRadio = null
|
||||
session = null
|
||||
|
||||
Cache.delete(context, "radio_type")
|
||||
Cache.delete(context, "radio_id")
|
||||
Cache.delete(context, "radio_session")
|
||||
Cache.delete(context, "radio_cookie")
|
||||
}
|
||||
|
||||
fun isActive() = currentRadio != null && session != null
|
||||
|
||||
private suspend fun createSession() {
|
||||
currentRadio?.let { radio ->
|
||||
try {
|
||||
val request = RadioSessionBody(radio.radio_type, related_object_id = radio.related_object_id).apply {
|
||||
if (radio_type == "custom") {
|
||||
custom_radio = radio.id
|
||||
}
|
||||
}
|
||||
|
||||
val body = Gson().toJson(request)
|
||||
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
|
||||
|
||||
session = result.get().id
|
||||
cookie = response.header("set-cookie").joinToString(";")
|
||||
|
||||
Cache.set(context, "radio_type", radio.radio_type.toByteArray())
|
||||
Cache.set(context, "radio_id", radio.id.toString().toByteArray())
|
||||
Cache.set(context, "radio_session", session.toString().toByteArray())
|
||||
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
|
||||
|
||||
prepareNextTrack(true)
|
||||
} catch (e: Exception) {
|
||||
withContext(Main) {
|
||||
context.toast(context.getString(R.string.radio_playback_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prepareNextTrack(first: Boolean = false) {
|
||||
session?.let { session ->
|
||||
try {
|
||||
val body = Gson().toJson(RadioTrackBody(session))
|
||||
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.apply {
|
||||
cookie?.let {
|
||||
header("cookie", it)
|
||||
}
|
||||
}
|
||||
.body(body)
|
||||
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
|
||||
|
||||
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
|
||||
.authorize()
|
||||
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
|
||||
|
||||
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
val track = trackResponse.get().apply {
|
||||
favorite = favorites.contains(id)
|
||||
}
|
||||
|
||||
if (first) {
|
||||
CommandBus.send(Command.ReplaceQueue(listOf(track), true))
|
||||
} else {
|
||||
CommandBus.send(Command.AddToQueue(listOf(track)))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Main) {
|
||||
context.toast(context.getString(R.string.radio_playback_error))
|
||||
}
|
||||
} finally {
|
||||
EventBus.send(Event.RadioStarted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.OtterResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "tracks-artist-$artistId"
|
||||
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -1,62 +1,94 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class FavoritesRepository(override val context: Context?) : Repository<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) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val body = mapOf("track" to id)
|
||||
|
||||
runBlocking(IO) {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/favorites/tracks/"))
|
||||
.header("Authorization", "Bearer $token")
|
||||
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(IO) {
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
|
||||
favoritedRepository.update(context, scope)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFavorite(id: Int) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val body = mapOf("track" to id)
|
||||
|
||||
runBlocking(IO) {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/"))
|
||||
.header("Authorization", "Bearer $token")
|
||||
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
request.header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(IO) {
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
|
||||
favoritedRepository.update(context, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
|
||||
override val cacheId = "favorited"
|
||||
override val upstream = HttpUpstream<Int, 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,99 +9,99 @@ import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
|||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
import com.github.kittinunf.result.Result
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.io.Reader
|
||||
import java.lang.reflect.Type
|
||||
import kotlin.math.ceil
|
||||
|
||||
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
|
||||
class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
|
||||
enum class Behavior {
|
||||
Single, AtOnce, Progressive
|
||||
}
|
||||
|
||||
private var _channel: Channel<Repository.Response<D>>? = null
|
||||
private val channel: Channel<Repository.Response<D>>
|
||||
get() {
|
||||
if (_channel?.isClosedForSend ?: true) {
|
||||
_channel = Channel()
|
||||
}
|
||||
|
||||
return _channel!!
|
||||
}
|
||||
|
||||
override fun fetch(size: Int): Channel<Repository.Response<D>>? {
|
||||
if (behavior == Behavior.Single && size != 0) return null
|
||||
override fun fetch(size: Int): Flow<Repository.Response<D>> = flow {
|
||||
if (behavior == Behavior.Single && size != 0) return@flow
|
||||
|
||||
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
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) {
|
||||
channel.offer(Repository.Response(Repository.Origin.Network, data, false))
|
||||
} else {
|
||||
channel.offer(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)
|
||||
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 -> channel.offer(Repository.Response(Repository.Origin.Network, listOf(), false))
|
||||
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}.flowOn(IO)
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
class GenericDeserializer<T : FunkwhaleResponse<*>>(val type: Type) : ResponseDeserializable<T> {
|
||||
class GenericDeserializer<T : OtterResponse<*>>(val type: Type) : ResponseDeserializable<T> {
|
||||
override fun deserialize(reader: Reader): T? {
|
||||
return Gson().fromJson(reader, type)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(url: String): Result<R, FuelError> {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
return try {
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
val (_, response, result) = Fuel
|
||||
.get(mustNormalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
}
|
||||
|
||||
return result
|
||||
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 token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
Fuel
|
||||
.get(mustNormalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResult(GenericDeserializer(type))
|
||||
request.awaitObjectResult(GenericDeserializer(type))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.error(FuelError.wrap(e))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,24 +1,29 @@
|
|||
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
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
|
||||
override val cacheId = "tracks-playlist-$playlistId"
|
||||
override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin).receive().data
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
data.map { track ->
|
||||
track.track.favorite = favorites.contains(track.track.id)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.OtterResponse
|
||||
import com.github.apognu.otter.utils.Radio
|
||||
import com.github.apognu.otter.utils.RadiosCache
|
||||
import com.github.apognu.otter.utils.RadiosResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
|
||||
override val cacheId = "radios"
|
||||
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken<RadiosResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Radio>) = RadiosCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Radio>): List<Radio> {
|
||||
return data
|
||||
.map { radio -> radio.apply { radio_type = "custom" } }
|
||||
.toMutableList()
|
||||
}
|
||||
}
|
|
@ -1,78 +1,59 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.CacheItem
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.io.BufferedReader
|
||||
import kotlin.math.ceil
|
||||
|
||||
interface Upstream<D> {
|
||||
fun fetch(size: Int = 0): Channel<Repository.Response<D>>?
|
||||
fun fetch(size: Int = 0): Flow<Repository.Response<D>>
|
||||
}
|
||||
|
||||
abstract class Repository<D : Any, C : CacheItem<D>> {
|
||||
protected val scope: CoroutineScope = CoroutineScope(Job() + IO)
|
||||
|
||||
enum class Origin(val origin: Int) {
|
||||
Cache(0b01),
|
||||
Network(0b10)
|
||||
}
|
||||
|
||||
data class Response<D>(val origin: Origin, val data: List<D>, 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?
|
||||
abstract val upstream: Upstream<D>
|
||||
|
||||
private var _channel: Channel<Response<D>>? = null
|
||||
private val channel: Channel<Response<D>>
|
||||
get() {
|
||||
if (_channel?.isClosedForSend ?: true) {
|
||||
_channel = Channel(10)
|
||||
}
|
||||
|
||||
return _channel!!
|
||||
}
|
||||
|
||||
open fun cache(data: List<D>): C? = null
|
||||
protected open fun uncache(reader: BufferedReader): C? = null
|
||||
|
||||
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, size: Int = 0): Channel<Response<D>> {
|
||||
if (Origin.Cache.origin and upstreams == upstreams) fromCache()
|
||||
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size)
|
||||
|
||||
return channel
|
||||
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, size: Int = 0): Flow<Response<D>> = flow {
|
||||
if (Origin.Cache.origin and upstreams == upstreams) fromCache().collect { emit(it) }
|
||||
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size).collect { emit(it) }
|
||||
}
|
||||
|
||||
private fun fromCache() {
|
||||
GlobalScope.launch(IO) {
|
||||
private fun fromCache() = flow {
|
||||
cacheId?.let { cacheId ->
|
||||
Cache.get(context, cacheId)?.let { reader ->
|
||||
uncache(reader)?.let { cache ->
|
||||
channel.offer(Response(Origin.Cache, cache.data, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
return@flow emit(Response(Origin.Cache, cache.data, ceil(cache.data.size / AppContext.PAGE_SIZE.toDouble()).toInt(), false))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromNetwork(size: Int) {
|
||||
upstream.fetch(size)?.untilNetwork(IO) { data, hasMore ->
|
||||
val data = onDataFetched(data)
|
||||
|
||||
cacheId?.let { cacheId ->
|
||||
Cache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(cache(data)).toByteArray()
|
||||
)
|
||||
return@flow emit(Response(Origin.Cache, listOf(), 1, false))
|
||||
}
|
||||
}.flowOn(IO)
|
||||
|
||||
channel.offer(Response(Origin.Network, data, hasMore))
|
||||
}
|
||||
private fun fromNetwork(size: Int) = flow {
|
||||
upstream
|
||||
.fetch(size)
|
||||
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.page, response.hasMore) }
|
||||
.collect { response -> emit(Response(Origin.Network, response.data, response.page, response.hasMore)) }
|
||||
}
|
||||
|
||||
protected open fun onDataFetched(data: List<D>) = data
|
||||
|
|
|
@ -1,28 +1,60 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class SearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() {
|
||||
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<Track, TracksCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream: Upstream<Track>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin).receive().data
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
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?, var query: String) : Repository<Artist, ArtistsCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream: Upstream<Artist>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Artist>) = ArtistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
|
||||
}
|
||||
|
||||
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream: Upstream<Album>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Album>) = AlbumsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
|
||||
}
|
|
@ -1,28 +1,61 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "tracks-album-$albumId"
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
companion object {
|
||||
fun getDownloadedIds(): List<Int>? {
|
||||
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
|
||||
val ids: MutableList<Int> = mutableListOf()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
ids.add(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin).receive().data
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
val downloaded = getDownloadedIds() ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
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
|
||||
}
|
||||
}.sortedWith(compareBy({ it.disc_number }, { it.position }))
|
||||
}
|
||||
}
|
|
@ -7,7 +7,6 @@ import android.app.NotificationManager
|
|||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.kittinunf.fuel.core.FuelManager
|
||||
|
@ -17,7 +16,9 @@ object AppContext {
|
|||
const val PREFS_CREDENTIALS = "credentials"
|
||||
|
||||
const val NOTIFICATION_MEDIA_CONTROL = 1
|
||||
const val NOTIFICATION_DOWNLOADS = 2
|
||||
const val NOTIFICATION_CHANNEL_MEDIA_CONTROL = "mediacontrols"
|
||||
const val NOTIFICATION_CHANNEL_DOWNLOADS = "downloads"
|
||||
|
||||
const val PAGE_SIZE = 50
|
||||
const val TRANSITION_DURATION = 300L
|
||||
|
@ -25,8 +26,6 @@ object AppContext {
|
|||
fun init(context: Activity) {
|
||||
setupNotificationChannels(context)
|
||||
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
// CastContext.getSharedInstance(context)
|
||||
|
||||
FuelManager.instance.addResponseInterceptor { next ->
|
||||
|
@ -65,6 +64,24 @@ object AppContext {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).let { manager ->
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_DOWNLOADS,
|
||||
"Downloads",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).run {
|
||||
description = "Downloads"
|
||||
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
|
||||
manager.createNotificationChannel(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
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.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.filter
|
||||
import kotlinx.coroutines.channels.map
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class Command {
|
||||
class StartService(val command: Command) : Command()
|
||||
object RefreshService : Command()
|
||||
|
||||
object ToggleState : Command()
|
||||
|
@ -20,12 +22,22 @@ sealed class Command {
|
|||
class Seek(val progress: Int) : Command()
|
||||
|
||||
class AddToQueue(val tracks: List<Track>) : Command()
|
||||
class AddToPlaylist(val tracks: List<Track>) : Command()
|
||||
class PlayNext(val track: Track) : Command()
|
||||
class ReplaceQueue(val queue: List<Track>) : Command()
|
||||
class ReplaceQueue(val queue: List<Track>, val fromRadio: Boolean = false) : Command()
|
||||
class RemoveFromQueue(val track: Track) : Command()
|
||||
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
|
||||
object ClearQueue : Command()
|
||||
object ShuffleQueue : Command()
|
||||
class PlayRadio(val radio: Radio) : Command()
|
||||
|
||||
class SetRepeatMode(val mode: Int) : Command()
|
||||
|
||||
class PlayTrack(val index: Int) : Command()
|
||||
class PinTrack(val track: Track) : Command()
|
||||
class PinTracks(val tracks: List<Track>) : Command()
|
||||
|
||||
class RefreshTrack(val track: Track?) : Command()
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
|
@ -34,75 +46,70 @@ sealed class Event {
|
|||
class PlaybackError(val message: String) : Event()
|
||||
object PlaybackStopped : Event()
|
||||
class Buffering(val value: Boolean) : Event()
|
||||
class TrackPlayed(val track: Track?, val play: Boolean) : Event()
|
||||
class TrackFinished(val track: Track?) : Event()
|
||||
class StateChanged(val playing: Boolean) : Event()
|
||||
object QueueChanged : Event()
|
||||
object RadioStarted : Event()
|
||||
object ListingsChanged : Event()
|
||||
class DownloadChanged(val download: Download) : Event()
|
||||
}
|
||||
|
||||
sealed class Request(var channel: Channel<Response>? = null) {
|
||||
object GetState : Request()
|
||||
object GetQueue : Request()
|
||||
object GetCurrentTrack : Request()
|
||||
object GetDownloads : Request()
|
||||
}
|
||||
|
||||
sealed class Response {
|
||||
class State(val playing: Boolean) : Response()
|
||||
class Queue(val queue: List<Track>) : Response()
|
||||
class CurrentTrack(val track: Track?) : Response()
|
||||
class Downloads(val cursor: DownloadCursor) : Response()
|
||||
}
|
||||
|
||||
object EventBus {
|
||||
fun send(event: Event) {
|
||||
GlobalScope.launch {
|
||||
get().offer(event)
|
||||
GlobalScope.launch(IO) {
|
||||
Otter.get().eventBus.offer(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = Otter.get().eventBus
|
||||
|
||||
inline fun <reified T : Event> asChannel(): ReceiveChannel<T> {
|
||||
return get().openSubscription().filter { it is T }.map { it as T }
|
||||
}
|
||||
fun get() = Otter.get().eventBus.asFlow()
|
||||
}
|
||||
|
||||
object CommandBus {
|
||||
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
|
||||
|
||||
get().offer(request)
|
||||
Otter.get().requestBus.offer(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = Otter.get().requestBus
|
||||
|
||||
inline fun <reified T> asChannel(): ReceiveChannel<T> {
|
||||
return get().openSubscription().filter { it is T }.map { it as T }
|
||||
}
|
||||
fun get() = Otter.get().requestBus.asFlow()
|
||||
}
|
||||
|
||||
object ProgressBus {
|
||||
fun send(current: Int, duration: Int, percent: Int) {
|
||||
GlobalScope.launch {
|
||||
GlobalScope.launch(IO) {
|
||||
Otter.get().progressBus.send(Triple(current, duration, percent))
|
||||
}
|
||||
}
|
||||
|
||||
fun asChannel(): ReceiveChannel<Triple<Int, Int, Int>> {
|
||||
return Otter.get().progressBus.openSubscription()
|
||||
}
|
||||
fun get() = Otter.get().progressBus.asFlow().conflate()
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> Channel<Response>.wait(): T? {
|
|
@ -36,12 +36,13 @@ object HTTP {
|
|||
}
|
||||
|
||||
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
val (_, response, result) = Fuel
|
||||
.get(mustNormalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
||||
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
|
@ -52,12 +53,13 @@ object HTTP {
|
|||
|
||||
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
|
||||
return if (refresh()) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
Fuel
|
||||
.get(mustNormalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
||||
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
}
|
||||
|
@ -87,4 +89,10 @@ object Cache {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(context: Context?, key: String) = context?.let {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
delete()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +1,26 @@
|
|||
package com.github.apognu.otter.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.BrowseFragment
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.kittinunf.fuel.core.Request
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.gson.Gson
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.RequestCreator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
fun Context.getColor(colorRes: Int): Int {
|
||||
return ContextCompat.getColor(this, colorRes)
|
||||
}
|
||||
|
||||
inline fun <D> Channel<Repository.Response<D>>.await(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) {
|
||||
GlobalScope.launch(context) {
|
||||
this@await.receive().also {
|
||||
callback(it.data)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <D> Channel<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>, hasMore: Boolean) -> Unit) {
|
||||
GlobalScope.launch(context) {
|
||||
for (data in this@untilNetwork) {
|
||||
callback(data.data, data.hasMore)
|
||||
|
||||
if (data.origin == Repository.Origin.Network && !data.hasMore) {
|
||||
close()
|
||||
}
|
||||
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) {
|
||||
scope.launch(context) {
|
||||
collect { data ->
|
||||
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -79,6 +64,16 @@ 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 {
|
||||
return this.apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)
|
||||
|
|
|
@ -1,84 +1,126 @@
|
|||
package com.github.apognu.otter.utils
|
||||
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.preference.PowerPreference
|
||||
|
||||
data class User(
|
||||
val full_username: String
|
||||
)
|
||||
|
||||
sealed class CacheItem<D : Any>(val data: List<D>)
|
||||
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
|
||||
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
|
||||
class TracksCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
class PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
|
||||
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
|
||||
class 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 Covers(val original: String)
|
||||
data class RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : OtterResponse<Radio>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class Covers(val urls: CoverUrls)
|
||||
data class CoverUrls(val original: String)
|
||||
|
||||
typealias AlbumList = List<Album>
|
||||
|
||||
interface SearchResult {
|
||||
fun cover(): String?
|
||||
fun title(): String
|
||||
fun subtitle(): String
|
||||
}
|
||||
|
||||
data class Album(
|
||||
val id: Int,
|
||||
val artist: Artist,
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
) {
|
||||
val cover: Covers?,
|
||||
val release_date: String?
|
||||
) : SearchResult {
|
||||
data class Artist(val name: String)
|
||||
|
||||
override fun cover() = cover?.urls?.original
|
||||
override fun title() = title
|
||||
override fun subtitle() = artist.name
|
||||
}
|
||||
|
||||
data class Artist(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val albums: List<Album>?
|
||||
) {
|
||||
) : SearchResult {
|
||||
data class Album(
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
val cover: Covers?
|
||||
)
|
||||
|
||||
override fun cover(): String? = albums?.getOrNull(0)?.cover?.urls?.original
|
||||
override fun title() = name
|
||||
override fun subtitle() = "Artist"
|
||||
}
|
||||
|
||||
data class Track(
|
||||
val id: Int,
|
||||
val id: Int = 0,
|
||||
val title: String,
|
||||
val artist: Artist,
|
||||
val album: Album,
|
||||
val uploads: List<Upload>
|
||||
) {
|
||||
val album: Album?,
|
||||
val disc_number: Int = 0,
|
||||
val position: Int = 0,
|
||||
val uploads: List<Upload> = listOf(),
|
||||
val copyright: String? = null,
|
||||
val license: String? = null
|
||||
) : SearchResult {
|
||||
var current: Boolean = false
|
||||
var favorite: Boolean = false
|
||||
var cached: Boolean = false
|
||||
var downloaded: Boolean = false
|
||||
|
||||
companion object {
|
||||
fun fromDownload(download: DownloadInfo): Track = Track(
|
||||
id = download.id,
|
||||
title = download.title,
|
||||
artist = Artist(0, download.artist, listOf()),
|
||||
album = Album(0, Album.Artist(""), "", Covers(CoverUrls("")), ""),
|
||||
uploads = listOf(Upload(download.contentId, 0, 0))
|
||||
)
|
||||
}
|
||||
|
||||
data class Upload(
|
||||
val listen_url: String,
|
||||
|
@ -102,6 +144,10 @@ data class Track(
|
|||
else -> uploads.maxBy { it.bitrate } ?: uploads[0]
|
||||
}
|
||||
}
|
||||
|
||||
override fun cover() = album?.cover?.urls?.original
|
||||
override fun title() = title
|
||||
override fun subtitle() = artist.name
|
||||
}
|
||||
|
||||
data class Favorited(val track: Int)
|
||||
|
@ -115,3 +161,19 @@ data class Playlist(
|
|||
)
|
||||
|
||||
data class PlaylistTrack(val track: Track)
|
||||
|
||||
data class Radio(
|
||||
val id: Int,
|
||||
var radio_type: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
var related_object_id: String? = null
|
||||
)
|
||||
|
||||
data class DownloadInfo(
|
||||
val id: Int,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
var download: Download?
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,40 +12,69 @@ fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Any.log(message: String) {
|
||||
Log.d("FUNKWHALE", "${this.javaClass.simpleName}: $message")
|
||||
private fun logClassName(): String {
|
||||
val known = setOf(
|
||||
"dalvik.system.VMStack",
|
||||
"java.lang.Thread",
|
||||
"com.github.apognu.otter.utils.UtilKt"
|
||||
)
|
||||
|
||||
Thread.currentThread().stackTrace.forEach {
|
||||
if (!known.contains(it.className)) {
|
||||
val className = it.className.split('.').last()
|
||||
val line = it.lineNumber
|
||||
|
||||
return "$className:$line"
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeNormalizeUrl(url: String?): String? {
|
||||
if (url == null || url.isEmpty()) return null
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
fun Any?.log(prefix: String? = null) {
|
||||
prefix?.let {
|
||||
Log.d("OTTER", "${logClassName()} - $prefix: $this")
|
||||
} ?: Log.d("OTTER", "${logClassName()} - $this")
|
||||
}
|
||||
|
||||
fun maybeNormalizeUrl(rawUrl: String?): String? {
|
||||
try {
|
||||
if (rawUrl == null || rawUrl.isEmpty()) return null
|
||||
|
||||
return mustNormalizeUrl(rawUrl)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun mustNormalizeUrl(rawUrl: String): String {
|
||||
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
|
||||
val uri = URI(url).takeIf { it.host != null } ?: URI("$fallbackHost$url")
|
||||
val uri = URI(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
|
||||
|
||||
return uri.run {
|
||||
URI("https", host, path, query, null)
|
||||
}.toString()
|
||||
return uri.toString()
|
||||
}
|
||||
|
||||
fun mustNormalizeUrl(url: String): String {
|
||||
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
|
||||
val uri = URI(url).takeIf { it.host != null } ?: URI("$fallbackHost$url")
|
||||
|
||||
return uri.run {
|
||||
URI("https", host, path, query, null)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
fun toDurationString(seconds: Long): String {
|
||||
val days = (seconds / 86400)
|
||||
val hours = (seconds % 86400) / 3600
|
||||
val minutes = (seconds % 86400 % 3600) / 60
|
||||
fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
|
||||
val days = (duration / 86400)
|
||||
val hours = (duration % 86400) / 3600
|
||||
val minutes = (duration % 86400 % 3600) / 60
|
||||
val seconds = duration % 86400 % 3600 % 60
|
||||
|
||||
val ret = StringBuilder()
|
||||
|
||||
if (days > 0) ret.append("${days}d ")
|
||||
if (hours > 0) ret.append("${hours}h ")
|
||||
if (minutes > 0) ret.append("${minutes}m ")
|
||||
if (showSeconds && seconds > 0) ret.append("${seconds}s")
|
||||
|
||||
return ret.toString()
|
||||
}
|
||||
|
||||
object Settings {
|
||||
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
|
||||
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
|
||||
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
|
||||
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
|
||||
|
||||
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.github.apognu.otter.views
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.github.apognu.otter.R
|
||||
|
||||
object LoadingImageView {
|
||||
fun start(context: Context?, image: ImageView): ObjectAnimator? {
|
||||
context?.let {
|
||||
image.isEnabled = false
|
||||
image.setImageDrawable(context.getDrawable(R.drawable.fab_spinner))
|
||||
|
||||
return ObjectAnimator.ofFloat(image, View.ROTATION, 0f, 360f).apply {
|
||||
duration = 500
|
||||
repeatCount = ObjectAnimator.INFINITE
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun stop(context: Context?, original: Drawable, image: ImageView, animator: ObjectAnimator?) {
|
||||
context?.let {
|
||||
animator?.cancel()
|
||||
|
||||
image.isEnabled = true
|
||||
image.setImageDrawable(original)
|
||||
image.rotation = 0.0f
|
||||
}
|
||||
}
|
||||
}
|
|
@ -52,7 +52,7 @@ class NowPlayingView : MaterialCardView {
|
|||
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
|
||||
if (gestureDetectorCallback?.isScrolling == true) {
|
||||
gestureDetectorCallback?.onUp(motionEvent)
|
||||
gestureDetectorCallback?.onUp()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ class NowPlayingView : MaterialCardView {
|
|||
}
|
||||
|
||||
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
|
||||
var maxHeight = 0
|
||||
private var maxHeight = 0
|
||||
private var minHeight = 0
|
||||
private var maxMargin = 0
|
||||
|
||||
|
@ -100,12 +100,10 @@ class NowPlayingView : MaterialCardView {
|
|||
initialTouchY = e.rawY
|
||||
lastTouchY = e.rawY
|
||||
|
||||
flingAnimator?.cancel()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun onUp(event: MotionEvent): Boolean {
|
||||
fun onUp(): Boolean {
|
||||
isScrolling = false
|
||||
|
||||
layoutParams.let {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
otter@support.popineau.eu
|
|
@ -0,0 +1 @@
|
|||
https://github.com/apognu/otter
|
|
@ -0,0 +1 @@
|
|||
en-US
|
|
@ -0,0 +1,9 @@
|
|||
Otter is a simple music player that allows you to stream the audio content of your self-hosted Funkwhale pod.
|
||||
|
||||
This app requires an account on a Funkwhale instance to work.
|
||||
|
||||
You can get support or take a part in Otter's development by visiting our GitHub project or join us on Matrix.
|
||||
|
||||
Source code : https://github.com/apognu/otter
|
||||
Matrix room: https://matrix.to/#/#otter:matrix.org
|
||||
Funkwhale : https://funkwhale.audio
|
After Width: | Height: | Size: 302 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 441 KiB |
After Width: | Height: | Size: 967 KiB |
After Width: | Height: | Size: 1.6 MiB |
After Width: | Height: | Size: 2.6 MiB |
After Width: | Height: | Size: 2.4 MiB |
After Width: | Height: | Size: 226 KiB |
After Width: | Height: | Size: 621 KiB |
|
@ -0,0 +1 @@
|
|||
Music player for Funkwhale
|
|
@ -0,0 +1 @@
|
|||
Otter for Funkwhale
|
|
@ -0,0 +1,8 @@
|
|||
Otter est un lecteur de musique basique qui vous permet de profiter du contenu audio de votre instance Funkwhale.
|
||||
|
||||
Cette application nécessite un compte sur une instance Funkwhale pour fonctionner.
|
||||
|
||||
Vous pouvez obtenir de l'aide ou participer au développement d'Otter en vous rendant sur notre projet GitHub ou nous rejoindre sur Matrix.
|
||||
|
||||
Code source : https://github.com/apognu/otter
|
||||
Funkwhale : https://funkwhale.audio
|
|
@ -0,0 +1 @@
|
|||
Lecteur de musique pour Funkwhale
|
|
@ -0,0 +1 @@
|
|||
Otter pour Funkwhale
|
|
@ -0,0 +1 @@
|
|||
../../../../../../fastlane/metadata/android/en-US/changelogs/1000021.txt
|
|
@ -0,0 +1 @@
|
|||
../../../../../../fastlane/metadata/android/fr-FR/changelogs/1000021.txt
|
Before Width: | Height: | Size: 696 B |
After Width: | Height: | Size: 777 B |
Before Width: | Height: | Size: 469 B |
After Width: | Height: | Size: 501 B |
Before Width: | Height: | Size: 946 B |
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.6 KiB |