Compare commits
183 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 |
|
@ -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.
|
|
@ -17,7 +17,6 @@ jobs:
|
|||
- name: Build with Gradle
|
||||
run: |
|
||||
mkdir -p /home/runner/.android && touch /home/runner/.android/repositories.cfg
|
||||
echo y | sudo ${ANDROID_HOME}/tools/bin/sdkmanager ndk-bundle 'ndk;21.3.6528147'
|
||||
./gradlew assembleDebug
|
||||
- name: Create release
|
||||
uses: eine/tip@gha-tip
|
|
@ -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
|
44
.travis.yml
|
@ -1,44 +0,0 @@
|
|||
language: android
|
||||
dist: trusty
|
||||
if: tag != 'tip'
|
||||
|
||||
android:
|
||||
components:
|
||||
- platform-tools
|
||||
- tools
|
||||
- build-tools-29.0.3
|
||||
- android-29
|
||||
|
||||
before_cache:
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- "$HOME/.gradle/caches/"
|
||||
- "$HOME/.gradle/wrapper/"
|
||||
- "$HOME/.android/build-cache"
|
||||
|
||||
before_install:
|
||||
- yes | sdkmanager ndk-bundle 'ndk;21.3.6528147'
|
||||
- openssl aes-256-cbc -K $encrypted_532b6bc7108c_key -iv $encrypted_532b6bc7108c_iv -in dist/apognu.jks.enc -out dist/apognu.jks -d
|
||||
- echo -e "signing.store=/home/travis/build/apognu/otter/dist/apognu.jks\nsigning.key_passphrase=${keystore_passphrase}\nsigning.alias=release\nsigning.store_passphrase=${keystore_passphrase}" > local.properties
|
||||
|
||||
script:
|
||||
- "./gradlew app:assembleRelease"
|
||||
|
||||
before_deploy:
|
||||
- RELEASE_MESSAGE="$(git tag -ln --format '%(subject)' $TRAVIS_TAG)"
|
||||
|
||||
deploy:
|
||||
- provider: releases
|
||||
name: $TRAVIS_TAG
|
||||
body: $RELEASE_MESSAGE
|
||||
prerelease: false
|
||||
file: app/build/outputs/apk/release/app-release.apk
|
||||
overwrite: true
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
api_key:
|
||||
secure: CIgVCinr1VDsMAAjcU8rxVq5SA0kDK3yTmtZW8Hc5RoOhVlKH24VWzdIjWdPZyW3vEDSPlBehAoSCDFI7oZ8xH/SgeXIdFMFbpCUi7QXp+ZdK8MdduHsXXVsEvVlIKi4R8ZhpFF/oR/yKwUo3zDT3SNv5zKZQvae+OzWIt9hm95gm9A+HexgdG4NjjtaNNp+wmhWEO8BvyZV6ZN05o+Z/qQz5pHc8n/v4sLcaQltnErZBaW4wBKKIvwZ54TQrWewW2y2m2lARKt8IGcgWW1jwQD7rzP1hzz/UGLF0eMbFCHNm+r8go0YoB7UDXKfmsZuDsFhjqpQuP3rOlavtXdBB1wAfsbJB3u1mso54w9/M9r1hRK2oClmhLOKU4wYzHLhwwa658qcbTDCJ2+Zwf81/D2YADj3hAkCdpoc0hJoEKQXRdhnQ0yiqYy4p/3RYh7WqSsU7iwUCqyDVHVprv89Qh5iPdbxezYpXQVWyeLpy4+4cYWqjggyxBy0wz7LUu9uClg2M2uaFZ/Ud1FOqtHeDP+Q8hW0DcArYPHXVAySyiuNXAhS/SWFnmpuCtzCs5NmnYA9lmYr33u3+rDJ9+LHArFXTVdfxU2xaSsG0kyjNVxKa3UXEoz0tqBJpeihkWGtjq04sK2AeEEw8lAt6wafFY6jMra23qPQRTCBNPuUsqk=
|
11
README.md
|
@ -1,14 +1,16 @@
|
|||
# Otter for Funkwhale
|
||||
|
||||
![](https://img.shields.io/github/license/apognu/otter?style=flat-square)
|
||||
[![](https://img.shields.io/travis/apognu/otter/master?style=flat-square)](https://travis-ci.org/apognu/otter)
|
||||
[![](https://img.shields.io/github/workflow/status/apognu/otter/Continuous%20develop%20build?label=develop&style=flat-square)](https://github.com/apognu/otter/actions?query=workflow%3A%22Continuous+develop+build%22)
|
||||
[![](https://img.shields.io/badge/Play%20Store-otter-informational?style=flat-square)](https://play.google.com/store/apps/details?id=com.github.apognu.otter)
|
||||
[![](https://img.shields.io/badge/IzzySoft-otter-informational?style=flat-square)](https://apt.izzysoft.de/fdroid/index/apk/com.github.apognu.otter)
|
||||
[![](https://img.shields.io/badge/APK-otter-informational?style=flat-square)](https://github.com/apognu/otter/releases) [![](https://translate.funkwhale.audio/widgets/otter/-/android/svg-badge.svg)](https://translate.funkwhale.audio/projects/otter/android/)
|
||||
|
||||
Otter is a native Android music player for [Funkwhale](https://funkwhale.audio), native to both Android (developed in Kotlin) and to Funkwhale (uses its native API instead of Subsonic).
|
||||
|
||||
![Otter graphic](https://github.com/apognu/otter/raw/master/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png)
|
||||
You can get help and discuss Otter on Matrix on [#otter:matrix.org](https://matrix.to/#/#otter:matrix.org).
|
||||
|
||||
![Otter graphic](https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png)
|
||||
|
||||
## State
|
||||
|
||||
|
@ -22,11 +24,16 @@ Otter's features, as of this writing, are the following:
|
|||
* Track search
|
||||
* Queue management
|
||||
* Caching of played tracks (played tracks work offline)
|
||||
* Download tracks for offline playback
|
||||
* Radios playback
|
||||
* Dark mode! 🎉
|
||||
|
||||
Otter will try to behave as you would expect a mobile music player to, meaning integrating with the OS's media controls (including headset controls) or pause on incoming calls. If there is anything you would like it to do, please [open an issue](https://github.com/apognu/otter/issues/new).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/6.png" width="200" /> <img src="https://github.com/apognu/otter/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/7.png" width="200" />
|
||||
|
||||
## Translation
|
||||
|
||||
Otter is being translated by the community through [Weblate](https://translate.funkwhale.audio/projects/otter/android/). If you would like to contribute to its localization or add a new language, you can help out there.
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import org.gradle.kotlin.dsl.*
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
|
||||
import org.jetbrains.kotlin.konan.properties.hasProperty
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
@ -15,7 +13,10 @@ plugins {
|
|||
}
|
||||
|
||||
val props = Properties().apply {
|
||||
try { load(FileInputStream(rootProject.file("local.properties"))) } catch (e: Exception) {}
|
||||
try {
|
||||
load(FileInputStream(rootProject.file("local.properties")))
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
androidGitVersion {
|
||||
|
@ -30,9 +31,7 @@ android {
|
|||
}
|
||||
|
||||
kotlinOptions {
|
||||
(this as KotlinJvmOptions).apply {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
|
||||
buildToolsVersion = "29.0.3"
|
||||
|
@ -64,17 +63,36 @@ android {
|
|||
buildTypes {
|
||||
getByName("debug") {
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".dev"
|
||||
manifestPlaceholders = mapOf(
|
||||
"app_name" to "Otter (develop)"
|
||||
)
|
||||
|
||||
resValue("string", "debug.hostname", props.getProperty("debug.hostname", ""))
|
||||
resValue("string", "debug.username", props.getProperty("debug.username", ""))
|
||||
resValue("string", "debug.password", props.getProperty("debug.password", ""))
|
||||
}
|
||||
|
||||
getByName("release") {
|
||||
manifestPlaceholders = mapOf(
|
||||
"app_name" to "Otter"
|
||||
)
|
||||
|
||||
if (props.hasProperty("signing.store")) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
|
||||
isMinifyEnabled = false
|
||||
resValue("string", "debug.hostname", "")
|
||||
resValue("string", "debug.username", "")
|
||||
resValue("string", "debug.password", "")
|
||||
|
||||
proguardFile(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
proguardFile("proguard-rules.pro")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -95,33 +113,38 @@ play {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.61")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2")
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.1.0")
|
||||
implementation("androidx.core:core-ktx:1.4.0-alpha01")
|
||||
implementation("androidx.appcompat:appcompat:1.2.0")
|
||||
implementation("androidx.core:core-ktx:1.5.0-alpha02")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
|
||||
implementation("androidx.preference:preference:1.1.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.1.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||
implementation("com.google.android.material:material:1.2.0-alpha06")
|
||||
implementation("com.android.support.constraint:constraint-layout:1.1.3")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("com.google.android.material:material:1.3.0-alpha02")
|
||||
implementation("com.android.support.constraint:constraint-layout:2.0.1")
|
||||
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.10.5")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.10.6")
|
||||
// implementation("com.google.android.exoplayer:extension-cast:2.10.6")
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:2.10.5") {
|
||||
implementation("com.google.android.exoplayer:exoplayer-core:2.11.5")
|
||||
implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.11.5")
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:2.11.4") {
|
||||
isTransitive = false
|
||||
}
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:2.11.4" ){
|
||||
isTransitive = false
|
||||
}
|
||||
|
||||
implementation("com.aliassadi:power-preference-lib:1.4.1")
|
||||
implementation("com.github.kittinunf.fuel:fuel:2.1.0")
|
||||
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.1.0")
|
||||
implementation("com.github.kittinunf.fuel:fuel-android:2.1.0")
|
||||
implementation("com.github.kittinunf.fuel:fuel-gson:2.1.0")
|
||||
implementation("com.google.code.gson:gson:2.8.5")
|
||||
implementation("com.google.code.gson:gson:2.8.6")
|
||||
implementation("com.squareup.picasso:picasso:2.71828")
|
||||
implementation("jp.wasabeef:picasso-transformations:2.2.1")
|
||||
}
|
||||
|
|
|
@ -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: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 |
|
@ -2,13 +2,19 @@ package com.github.apognu.otter
|
|||
|
||||
import android.app.Application
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.Command
|
||||
import com.github.apognu.otter.utils.Event
|
||||
import com.github.apognu.otter.utils.Request
|
||||
import com.github.apognu.otter.playback.MediaSession
|
||||
import com.github.apognu.otter.playback.QueueManager
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.database.ExoDatabaseProvider
|
||||
import com.google.android.exoplayer2.offline.DefaultDownloadIndex
|
||||
import com.google.android.exoplayer2.offline.DefaultDownloaderFactory
|
||||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import com.google.android.exoplayer2.offline.DownloaderConstructorHelper
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.channels.BroadcastChannel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
@ -23,10 +29,40 @@ class Otter : Application() {
|
|||
var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
|
||||
val commandBus: Channel<Command> = Channel(10)
|
||||
val commandBus: BroadcastChannel<Command> = BroadcastChannel(10)
|
||||
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
|
||||
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
|
||||
|
||||
private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) }
|
||||
|
||||
val exoCache: SimpleCache by lazy {
|
||||
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().let {
|
||||
val cacheSize = if (it == 0L) 0 else it * 1024 * 1024 * 1024
|
||||
|
||||
SimpleCache(
|
||||
cacheDir.resolve("media"),
|
||||
LeastRecentlyUsedCacheEvictor(cacheSize),
|
||||
exoDatabase
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val exoDownloadCache: SimpleCache by lazy {
|
||||
SimpleCache(
|
||||
cacheDir.resolve("downloads"),
|
||||
NoOpCacheEvictor(),
|
||||
exoDatabase
|
||||
)
|
||||
}
|
||||
|
||||
val exoDownloadManager: DownloadManager by lazy {
|
||||
DownloaderConstructorHelper(exoDownloadCache, QueueManager.factory(this)).run {
|
||||
DownloadManager(this@Otter, DefaultDownloadIndex(exoDatabase), DefaultDownloaderFactory(this))
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession(this)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
@ -43,6 +79,16 @@ class Otter : Application() {
|
|||
}
|
||||
}
|
||||
|
||||
fun deleteAllData() {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
|
||||
|
||||
cacheDir.listFiles()?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
cacheDir.resolve("picasso-cache").deleteRecursively()
|
||||
}
|
||||
|
||||
inner class CrashReportHandler : Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
val now = Date(Date().time - (5 * 60 * 1000))
|
||||
|
|
|
@ -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,13 +1,18 @@
|
|||
package com.github.apognu.otter.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.LoginDialog
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Userinfo
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
|
@ -16,7 +21,6 @@ import com.google.gson.Gson
|
|||
import com.preference.PowerPreference
|
||||
import kotlinx.android.synthetic.main.activity_login.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class FwCredentials(val token: String, val non_field_errors: List<String>?)
|
||||
|
@ -26,6 +30,8 @@ class LoginActivity : AppCompatActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_login)
|
||||
|
||||
limitContainerWidth()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -50,11 +56,23 @@ class LoginActivity : AppCompatActivity() {
|
|||
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
|
||||
|
||||
Uri.parse(hostname).apply {
|
||||
if (scheme == "http") {
|
||||
if (!cleartext.isChecked && scheme == "http") {
|
||||
throw Exception(getString(R.string.login_error_hostname_https))
|
||||
}
|
||||
|
||||
if (scheme == null) hostname = "https://$hostname"
|
||||
if (scheme == null) {
|
||||
hostname = when (cleartext.isChecked) {
|
||||
true -> "http://$hostname"
|
||||
false -> "https://$hostname"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostname_field.error = ""
|
||||
|
||||
when (anonymous.isChecked) {
|
||||
false -> authedLogin(hostname, username, password)
|
||||
true -> anonymousLogin(hostname)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message =
|
||||
|
@ -63,16 +81,15 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
hostname_field.error = message
|
||||
}
|
||||
|
||||
hostname_field.error = ""
|
||||
|
||||
when (anonymous.isChecked) {
|
||||
false -> authedLogin(hostname, username, password)
|
||||
true -> anonymousLogin(hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
limitContainerWidth()
|
||||
}
|
||||
|
||||
private fun authedLogin(hostname: String, username: String, password: String) {
|
||||
val body = mapOf(
|
||||
"username" to username,
|
||||
|
@ -83,7 +100,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
show(supportFragmentManager, "LoginDialog")
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
try {
|
||||
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
|
@ -98,9 +115,14 @@ class LoginActivity : AppCompatActivity() {
|
|||
setString("access_token", result.get().token)
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
finish()
|
||||
Userinfo.get()?.let {
|
||||
dialog.dismiss()
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
|
||||
return@launch finish()
|
||||
}
|
||||
|
||||
throw Exception(getString(R.string.login_error_userinfo))
|
||||
}
|
||||
|
||||
is Result.Failure -> {
|
||||
|
@ -135,7 +157,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
show(supportFragmentManager, "LoginDialog")
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
try {
|
||||
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
|
@ -169,4 +191,16 @@ class LoginActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun limitContainerWidth() {
|
||||
container.doOnLayout {
|
||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && container.width >= 1440) {
|
||||
container.layoutParams.width = 1440
|
||||
} else {
|
||||
container.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
container.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,8 +5,8 @@ import android.animation.AnimatorListenerAdapter
|
|||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
|
@ -19,25 +19,32 @@ import androidx.core.graphics.drawable.toDrawable
|
|||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.*
|
||||
import com.github.apognu.otter.playback.MediaControlsManager
|
||||
import com.github.apognu.otter.playback.PinService
|
||||
import com.github.apognu.otter.playback.PlayerService
|
||||
import com.github.apognu.otter.repositories.FavoritedRepository
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.apognu.otter.views.DisableableFrameLayout
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.offline.DownloadService
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.partial_now_playing.*
|
||||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -48,7 +55,8 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private val favoriteRepository = FavoritesRepository(this)
|
||||
private val favoriteCheckRepository = FavoritedRepository(this)
|
||||
private val favoritedRepository = FavoritedRepository(this)
|
||||
private var menu: Menu? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -68,14 +76,31 @@ class MainActivity : AppCompatActivity() {
|
|||
.commit()
|
||||
|
||||
watchEventBus()
|
||||
|
||||
CommandBus.send(Command.RefreshService)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
|
||||
if (now_playing.isOpened()) {
|
||||
now_playing.close()
|
||||
|
||||
return@setShouldRegisterTouch false
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
favoritedRepository.update(this, lifecycleScope)
|
||||
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
DownloadService.start(this, PinService::class.java)
|
||||
|
||||
CommandBus.send(Command.RefreshService)
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
Userinfo.get()
|
||||
}
|
||||
|
||||
now_playing_toggle.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
|
@ -123,10 +148,22 @@ class MainActivity : AppCompatActivity() {
|
|||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
this.menu = menu
|
||||
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.toolbar, menu)
|
||||
|
||||
// CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.cast)
|
||||
menu?.findItem(R.id.nav_all_music)?.let {
|
||||
it.isChecked = Settings.getScopes().contains("all")
|
||||
it.isEnabled = !it.isChecked
|
||||
}
|
||||
|
||||
menu?.findItem(R.id.nav_my_music)?.isChecked = Settings.getScopes().contains("me")
|
||||
menu?.findItem(R.id.nav_followed)?.isChecked = Settings.getScopes().contains("subscribed")
|
||||
|
||||
return true
|
||||
}
|
||||
|
@ -147,6 +184,63 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
R.id.nav_queue -> launchDialog(QueueFragment())
|
||||
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
|
||||
R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
|
||||
menu?.let { menu ->
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
|
||||
item.actionView = View(this)
|
||||
item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||
override fun onMenuItemActionExpand(item: MenuItem?) = false
|
||||
override fun onMenuItemActionCollapse(item: MenuItem?) = false
|
||||
})
|
||||
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
val scopes = Settings.getScopes().toMutableSet()
|
||||
|
||||
val new = when (item.itemId) {
|
||||
R.id.nav_my_music -> "me"
|
||||
R.id.nav_followed -> "subscribed"
|
||||
|
||||
else -> {
|
||||
menu.findItem(R.id.nav_all_music).isEnabled = false
|
||||
menu.findItem(R.id.nav_my_music).isChecked = false
|
||||
menu.findItem(R.id.nav_followed).isChecked = false
|
||||
|
||||
PowerPreference.getDefaultFile().set("scope", "all")
|
||||
EventBus.send(Event.ListingsChanged)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
menu.findItem(R.id.nav_all_music).let {
|
||||
it.isChecked = false
|
||||
it.isEnabled = true
|
||||
}
|
||||
|
||||
scopes.remove("all")
|
||||
|
||||
when (item.isChecked) {
|
||||
true -> scopes.add(new)
|
||||
false -> scopes.remove(new)
|
||||
}
|
||||
|
||||
if (scopes.isEmpty()) {
|
||||
menu.findItem(R.id.nav_all_music).let {
|
||||
it.isChecked = true
|
||||
it.isEnabled = false
|
||||
}
|
||||
|
||||
scopes.add("all")
|
||||
}
|
||||
|
||||
PowerPreference.getDefaultFile().set("scope", scopes.joinToString(","))
|
||||
EventBus.send(Event.ListingsChanged)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
R.id.nav_downloads -> startActivity(Intent(this, DownloadsActivity::class.java))
|
||||
R.id.settings -> startActivityForResult(Intent(this, SettingsActivity::class.java), 0)
|
||||
}
|
||||
|
||||
|
@ -158,18 +252,17 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
if (resultCode == ResultCode.LOGOUT.code) {
|
||||
Intent(this, LoginActivity::class.java).apply {
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
|
||||
stopService(Intent(this@MainActivity, PlayerService::class.java))
|
||||
startActivity(this)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
|
||||
private fun launchFragment(fragment: Fragment) {
|
||||
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
|
||||
oldFragment.enterTransition = null
|
||||
|
@ -193,11 +286,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
@SuppressLint("NewApi")
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.LogOut -> {
|
||||
PowerPreference.clearAllData()
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
startActivity(Intent(this@MainActivity, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
|
@ -239,8 +332,6 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
is Event.TrackPlayed -> refreshCurrentTrack(message.track)
|
||||
is Event.RefreshTrack -> refreshCurrentTrack(message.track)
|
||||
is Event.TrackFinished -> incrementListenCount(message.track)
|
||||
|
||||
is Event.StateChanged -> {
|
||||
|
@ -270,7 +361,34 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.StartService -> {
|
||||
Build.VERSION_CODES.O.onApi(
|
||||
{
|
||||
startForegroundService(Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
})
|
||||
},
|
||||
{
|
||||
startService(Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
|
||||
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(this@MainActivity, lifecycleScope, command.tracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
ProgressBus.get().collect { (current, duration, percent) ->
|
||||
now_playing_progress.progress = percent
|
||||
now_playing_details_progress.progress = percent
|
||||
|
@ -319,27 +437,28 @@ class MainActivity : AppCompatActivity() {
|
|||
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(now_playing_cover)
|
||||
|
||||
now_playing_details_cover?.let { now_playing_details_cover ->
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(now_playing_details_cover)
|
||||
}
|
||||
|
||||
if (now_playing_details_cover == null) {
|
||||
GlobalScope.launch(IO) {
|
||||
lifecycleScope.launch(Default) {
|
||||
val width = DisplayMetrics().apply {
|
||||
windowManager.defaultDisplay.getMetrics(this)
|
||||
}.widthPixels
|
||||
|
||||
val backgroundCover = Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.get()
|
||||
.run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) }
|
||||
.apply {
|
||||
|
@ -370,7 +489,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_info_artist -> ArtistsFragment.openAlbums(this@MainActivity, track.artist, art = track.album.cover.original)
|
||||
R.id.track_info_artist -> ArtistsFragment.openAlbums(this@MainActivity, track.artist, art = track.album?.cover())
|
||||
R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album)
|
||||
R.id.track_info_details -> TrackInfoDetailsFragment.new(track).show(supportFragmentManager, "dialog")
|
||||
}
|
||||
|
@ -386,8 +505,8 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
now_playing_details_favorite?.let { now_playing_details_favorite ->
|
||||
favoriteCheckRepository.fetch().untilNetwork(IO) { favorites, _, _ ->
|
||||
GlobalScope.launch(Main) {
|
||||
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
|
||||
lifecycleScope.launch(Main) {
|
||||
track.favorite = favorites.contains(track.id)
|
||||
|
||||
when (track.favorite) {
|
||||
|
@ -414,6 +533,10 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
favoriteRepository.fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
|
||||
now_playing_details_add_to_playlist.setOnClickListener {
|
||||
CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -456,13 +579,16 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private fun incrementListenCount(track: Track?) {
|
||||
track?.let {
|
||||
GlobalScope.launch(IO) {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(mapOf("track" to track.id)))
|
||||
.awaitStringResponse()
|
||||
lifecycleScope.launch(IO) {
|
||||
try {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(mapOf("track" to track.id)))
|
||||
.awaitStringResponse()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,16 +3,22 @@ package com.github.apognu.otter.activities
|
|||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.SearchAdapter
|
||||
import com.github.apognu.otter.fragments.AddToPlaylistDialog
|
||||
import com.github.apognu.otter.fragments.AlbumsFragment
|
||||
import com.github.apognu.otter.fragments.ArtistsFragment
|
||||
import com.github.apognu.otter.repositories.*
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.Artist
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import kotlinx.android.synthetic.main.activity_search.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
|
@ -25,6 +31,8 @@ class SearchActivity : AppCompatActivity() {
|
|||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
var done = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -34,26 +42,51 @@ class SearchActivity : AppCompatActivity() {
|
|||
results.layoutManager = LinearLayoutManager(this)
|
||||
results.adapter = it
|
||||
}
|
||||
|
||||
search.requestFocus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
search.requestFocus()
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(this@SearchActivity, lifecycleScope, command.tracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artistsRepository = ArtistsSearchRepository(this@SearchActivity, "")
|
||||
albumsRepository = AlbumsSearchRepository(this@SearchActivity, "")
|
||||
tracksRepository = TracksSearchRepository(this@SearchActivity, "")
|
||||
favoritesRepository = FavoritesRepository(this@SearchActivity)
|
||||
|
||||
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(rawQuery: String?): Boolean {
|
||||
search.clearFocus()
|
||||
|
||||
rawQuery?.let {
|
||||
done = 0
|
||||
|
||||
val query = URLEncoder.encode(it, "UTF-8")
|
||||
|
||||
tracksRepository = TracksSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
|
||||
albumsRepository = AlbumsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
|
||||
artistsRepository = ArtistsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT))
|
||||
favoritesRepository = FavoritesRepository(this@SearchActivity)
|
||||
artistsRepository.query = query.toLowerCase(Locale.ROOT)
|
||||
albumsRepository.query = query.toLowerCase(Locale.ROOT)
|
||||
tracksRepository.query = query.toLowerCase(Locale.ROOT)
|
||||
|
||||
search_spinner.visibility = View.VISIBLE
|
||||
search_empty.visibility = View.GONE
|
||||
search_no_results.visibility = View.GONE
|
||||
|
||||
adapter.artists.clear()
|
||||
|
@ -61,34 +94,25 @@ class SearchActivity : AppCompatActivity() {
|
|||
adapter.tracks.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { artists, _, _ ->
|
||||
when (artists.isEmpty()) {
|
||||
true -> search_no_results.visibility = View.VISIBLE
|
||||
false -> adapter.artists.addAll(artists)
|
||||
}
|
||||
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { artists, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
adapter.artists.addAll(artists)
|
||||
refresh()
|
||||
}
|
||||
|
||||
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { albums, _, _ ->
|
||||
when (albums.isEmpty()) {
|
||||
true -> search_no_results.visibility = View.VISIBLE
|
||||
false -> adapter.albums.addAll(albums)
|
||||
}
|
||||
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { albums, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
adapter.albums.addAll(albums)
|
||||
refresh()
|
||||
}
|
||||
|
||||
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _, _ ->
|
||||
search_spinner.visibility = View.GONE
|
||||
search_empty.visibility = View.GONE
|
||||
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { tracks, _, _, _ ->
|
||||
done++
|
||||
|
||||
when (tracks.isEmpty()) {
|
||||
true -> search_no_results.visibility = View.VISIBLE
|
||||
false -> adapter.tracks.addAll(tracks)
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
adapter.tracks.addAll(tracks)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,6 +123,33 @@ class SearchActivity : AppCompatActivity() {
|
|||
})
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) {
|
||||
search_no_results.visibility = View.VISIBLE
|
||||
} else {
|
||||
search_no_results.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (done == 3) {
|
||||
search_spinner.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.tracks.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.tracks[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(adapter.getPositionOf(SearchAdapter.ResultType.Track, match.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
|
||||
override fun onArtistClick(holder: View?, artist: Artist) {
|
||||
ArtistsFragment.openAlbums(this@SearchActivity, artist)
|
||||
|
|
|
@ -11,9 +11,11 @@ import androidx.preference.Preference
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SeekBarPreference
|
||||
import com.github.apognu.otter.BuildConfig
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.preference.PowerPreference
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.Command
|
||||
import com.github.apognu.otter.utils.CommandBus
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -78,12 +80,10 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
|
|||
.setTitle(context.getString(R.string.logout_title))
|
||||
.setMessage(context.getString(R.string.logout_content))
|
||||
.setPositiveButton(android.R.string.yes) { _, _ ->
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
|
||||
|
||||
context.cacheDir.deleteRecursively()
|
||||
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
|
||||
activity?.finish()
|
||||
}
|
||||
|
@ -112,6 +112,14 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
|
|||
}
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<ListPreference>("play_order")?.let {
|
||||
it.summary = when (it.value) {
|
||||
"shuffle" -> activity.getString(R.string.settings_play_order_shuffle_summary)
|
||||
"in_order" -> activity.getString(R.string.settings_play_order_in_order_summary)
|
||||
else -> activity.getString(R.string.settings_play_order_shuffle_summary)
|
||||
}
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<ListPreference>("night_mode")?.let {
|
||||
when (it.value) {
|
||||
"on" -> {
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Settings
|
||||
|
||||
|
@ -20,6 +21,8 @@ class SplashActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
|
||||
Otter.get().deleteAllData()
|
||||
|
||||
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
|
||||
|
||||
startActivity(this)
|
||||
|
|
|
@ -6,7 +6,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.maybeLoad
|
||||
import com.github.apognu.otter.utils.maybeNormalizeUrl
|
||||
|
@ -15,11 +15,13 @@ import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
|||
import kotlinx.android.synthetic.main.row_album.view.*
|
||||
import kotlinx.android.synthetic.main.row_artist.view.art
|
||||
|
||||
class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() {
|
||||
class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickListener) : OtterAdapter<Album, AlbumsAdapter.ViewHolder>() {
|
||||
interface OnAlbumClickListener {
|
||||
fun onClick(view: View?, album: Album)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long = data[position].id.toLong()
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
|
@ -34,19 +36,28 @@ class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickLis
|
|||
val album = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
|
||||
holder.title.text = album.title
|
||||
holder.artist.text = album.artist.name
|
||||
holder.release_date.visibility = View.GONE
|
||||
|
||||
album.release_date?.split('-')?.getOrNull(0)?.let { year ->
|
||||
if (year.isNotEmpty()) {
|
||||
holder.release_date.visibility = View.VISIBLE
|
||||
holder.release_date.text = year
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, private val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val art = view.art
|
||||
val title = view.title
|
||||
val artist = view.artist
|
||||
val release_date = view.release_date
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(view, data[layoutPosition])
|
||||
|
|
|
@ -6,7 +6,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.maybeLoad
|
||||
import com.github.apognu.otter.utils.maybeNormalizeUrl
|
||||
|
@ -14,11 +14,13 @@ import com.squareup.picasso.Picasso
|
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_album_grid.view.*
|
||||
|
||||
class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsGridAdapter.ViewHolder>() {
|
||||
class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClickListener) : OtterAdapter<Album, AlbumsGridAdapter.ViewHolder>() {
|
||||
interface OnAlbumClickListener {
|
||||
fun onClick(view: View?, album: Album)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long = data[position].id.toLong()
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
|
@ -33,7 +35,7 @@ class AlbumsGridAdapter(val context: Context?, private val listener: OnAlbumClic
|
|||
val album = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover()))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
|
|
|
@ -6,7 +6,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.Artist
|
||||
import com.github.apognu.otter.utils.maybeLoad
|
||||
import com.github.apognu.otter.utils.maybeNormalizeUrl
|
||||
|
@ -14,14 +14,32 @@ import com.squareup.picasso.Picasso
|
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_artist.view.*
|
||||
|
||||
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : FunkwhaleAdapter<Artist, ArtistsAdapter.ViewHolder>() {
|
||||
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : OtterAdapter<Artist, ArtistsAdapter.ViewHolder>() {
|
||||
private var active: List<Artist> = mutableListOf()
|
||||
|
||||
interface OnArtistClickListener {
|
||||
fun onClick(holder: View?, artist: Artist)
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
init {
|
||||
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
active = data.filter { it.albums?.isNotEmpty() ?: false }
|
||||
|
||||
override fun getItemId(position: Int) = data[position].id.toLong()
|
||||
super.onChanged()
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
active = data.filter { it.albums?.isNotEmpty() ?: false }
|
||||
|
||||
super.onItemRangeInserted(positionStart, itemCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getItemCount() = active.size
|
||||
|
||||
override fun getItemId(position: Int) = active[position].id.toLong()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false)
|
||||
|
@ -32,12 +50,12 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val artist = data[position]
|
||||
val artist = active[position]
|
||||
|
||||
artist.albums?.let { albums ->
|
||||
if (albums.isNotEmpty()) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(albums[0].cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
|
@ -59,7 +77,7 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
|
|||
val albums = view.albums
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(view, data[layoutPosition])
|
||||
listener.onClick(view, active[layoutPosition])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,19 +53,14 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
|
|||
holder.title.text = favorite.title
|
||||
holder.artist.text = favorite.artist.name
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (favorite.id == currentTrack?.id) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
|
@ -74,6 +69,23 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
|
|||
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
|
||||
when (favorite.cached || favorite.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (favorite.cached && !favorite.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (favorite.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener.onToggleFavorite(favorite.id, !favorite.favorite)
|
||||
|
||||
|
@ -91,6 +103,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
|
|||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(favorite))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite))
|
||||
}
|
||||
|
||||
|
|
|
@ -3,26 +3,30 @@ package com.github.apognu.otter.adapters
|
|||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Build
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_track.view.*
|
||||
import java.util.*
|
||||
|
||||
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : FunkwhaleAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
|
||||
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, private val playlistListener: OnPlaylistListener? = null) : OtterAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
||||
interface OnPlaylistListener {
|
||||
fun onMoveTrack(from: Int, to: Int)
|
||||
fun onRemoveTrackFromPlaylist(track: Track, index: Int)
|
||||
}
|
||||
|
||||
private lateinit var touchHelper: ItemTouchHelper
|
||||
|
||||
var currentTrack: Track? = null
|
||||
|
@ -36,10 +40,8 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
|
||||
if (fromQueue) {
|
||||
touchHelper = ItemTouchHelper(TouchHelperCallback()).also {
|
||||
it.attachToRecyclerView(recyclerView)
|
||||
}
|
||||
touchHelper = ItemTouchHelper(TouchHelperCallback()).also {
|
||||
it.attachToRecyclerView(recyclerView)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,7 +58,7 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
val track = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.track.album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
|
@ -65,19 +67,14 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
holder.title.text = track.track.title
|
||||
holder.artist.text = track.track.artist.name
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (track.track == currentTrack) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
if (track.track == currentTrack || track.track.current) {
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
|
@ -99,13 +96,16 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
holder.actions.setOnClickListener {
|
||||
context?.let { context ->
|
||||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track)
|
||||
inflate(R.menu.row_track)
|
||||
|
||||
menu.findItem(R.id.track_remove_from_playlist).isVisible = true
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track))
|
||||
R.id.track_remove_from_playlist -> playlistListener?.onRemoveTrackFromPlaylist(track.track, position)
|
||||
}
|
||||
|
||||
true
|
||||
|
@ -116,16 +116,14 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
}
|
||||
}
|
||||
|
||||
if (fromQueue) {
|
||||
holder.handle.visibility = View.VISIBLE
|
||||
holder.handle.visibility = View.VISIBLE
|
||||
|
||||
holder.handle.setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
touchHelper.startDrag(holder)
|
||||
}
|
||||
|
||||
true
|
||||
holder.handle.setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
touchHelper.startDrag(holder)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,13 +133,12 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
Collections.swap(data, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in newPosition.downTo(oldPosition)) {
|
||||
for (i in oldPosition.downTo(newPosition + 1)) {
|
||||
Collections.swap(data, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
notifyItemMoved(oldPosition, newPosition)
|
||||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
|
@ -154,20 +151,18 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
val actions = view.actions
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
when (fromQueue) {
|
||||
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
|
||||
false -> {
|
||||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
|
||||
CommandBus.send(Command.ReplaceQueue(this.map { it.track }))
|
||||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
|
||||
CommandBus.send(Command.ReplaceQueue(this.map { it.track }))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
|
||||
var from = -1
|
||||
var to = -1
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
|
||||
override fun isItemViewSwipeEnabled() = false
|
||||
|
@ -176,6 +171,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
if (from == -1) from = viewHolder.adapterPosition
|
||||
to = target.adapterPosition
|
||||
|
||||
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
|
||||
|
||||
return true
|
||||
|
@ -185,7 +183,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
viewHolder?.itemView?.background = ColorDrawable(Color.argb(255, 100, 100, 100))
|
||||
context?.let {
|
||||
viewHolder?.itemView?.background = ColorDrawable(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
}
|
||||
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
|
@ -194,6 +194,13 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
|
|||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
|
||||
|
||||
if (from != -1 && to != -1 && from != to) {
|
||||
playlistListener?.onMoveTrack(from, to)
|
||||
|
||||
from = -1
|
||||
to = -1
|
||||
}
|
||||
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,15 +4,17 @@ import android.content.Context
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.Playlist
|
||||
import com.github.apognu.otter.utils.toDurationString
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_playlist.view.*
|
||||
|
||||
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : FunkwhaleAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
|
||||
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : OtterAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
|
||||
interface OnPlaylistClickListener {
|
||||
fun onClick(holder: View?, playlist: Playlist)
|
||||
}
|
||||
|
@ -35,6 +37,15 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
|
|||
holder.name.text = playlist.name
|
||||
holder.summary.text = context?.resources?.getQuantityString(R.plurals.playlist_description, playlist.tracks_count, playlist.tracks_count, toDurationString(playlist.duration.toLong())) ?: ""
|
||||
|
||||
context?.let {
|
||||
ContextCompat.getDrawable(context, R.drawable.cover).let {
|
||||
holder.cover_top_left.setImageDrawable(it)
|
||||
holder.cover_top_right.setImageDrawable(it)
|
||||
holder.cover_bottom_left.setImageDrawable(it)
|
||||
holder.cover_bottom_right.setImageDrawable(it)
|
||||
}
|
||||
}
|
||||
|
||||
playlist.album_covers.shuffled().take(4).forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> holder.cover_top_left
|
||||
|
@ -44,8 +55,17 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
|
|||
else -> holder.cover_top_left
|
||||
}
|
||||
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.load(url)
|
||||
.transform(RoundedCornersTransformation(32, 0, corner))
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,59 +6,128 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Event
|
||||
import com.github.apognu.otter.utils.EventBus
|
||||
import com.github.apognu.otter.utils.Radio
|
||||
import com.github.apognu.otter.views.LoadingImageView
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.android.synthetic.main.row_radio.view.*
|
||||
import kotlinx.android.synthetic.main.row_radio_header.view.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadiosAdapter(val context: Context?, private val listener: OnRadioClickListener) : FunkwhaleAdapter<Radio, RadiosAdapter.ViewHolder>() {
|
||||
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<Radio, RadiosAdapter.ViewHolder>() {
|
||||
interface OnRadioClickListener {
|
||||
fun onClick(holder: ViewHolder, radio: Radio)
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
enum class RowType {
|
||||
Header,
|
||||
InstanceRadio,
|
||||
UserRadio
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int) = data[position].id.toLong()
|
||||
private val instanceRadios: List<Radio> by lazy {
|
||||
context?.let {
|
||||
return@lazy when (val username = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
|
||||
"" -> listOf(
|
||||
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
|
||||
)
|
||||
|
||||
else -> listOf(
|
||||
Radio(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
|
||||
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
|
||||
Radio(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
|
||||
Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf<Radio>()
|
||||
}
|
||||
|
||||
private fun getRadioAt(position: Int): Radio {
|
||||
return when (getItemViewType(position)) {
|
||||
RowType.InstanceRadio.ordinal -> instanceRadios[position - 1]
|
||||
else -> data[position - instanceRadios.size - 2]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int) = when (getItemViewType(position)) {
|
||||
RowType.InstanceRadio.ordinal -> (-position - 1).toLong()
|
||||
RowType.Header.ordinal -> Long.MIN_VALUE
|
||||
else -> getRadioAt(position).id.toLong()
|
||||
}
|
||||
|
||||
override fun getItemCount() = instanceRadios.size + data.size + 2
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when {
|
||||
position == 0 || position == instanceRadios.size + 1 -> RowType.Header.ordinal
|
||||
position <= instanceRadios.size -> RowType.InstanceRadio.ordinal
|
||||
else -> RowType.UserRadio.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
|
||||
return when (viewType) {
|
||||
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
|
||||
|
||||
return ViewHolder(view, listener).also {
|
||||
view.setOnClickListener(it)
|
||||
ViewHolder(view, listener).also {
|
||||
view.setOnClickListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
else -> ViewHolder(LayoutInflater.from(context).inflate(R.layout.row_radio_header, parent, false), null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) {
|
||||
val radio = data[position]
|
||||
|
||||
holder.art.visibility = View.VISIBLE
|
||||
holder.name.text = radio.name
|
||||
holder.description.text = radio.description
|
||||
|
||||
context?.let { context ->
|
||||
val icon = when (radio.radio_type) {
|
||||
"random" -> R.drawable.shuffle
|
||||
"less-listened" -> R.drawable.sad
|
||||
else -> null
|
||||
when (getItemViewType(position)) {
|
||||
RowType.Header.ordinal -> {
|
||||
context?.let {
|
||||
when (position) {
|
||||
0 -> holder.label.text = context.getString(R.string.radio_instance_radios)
|
||||
instanceRadios.size + 1 -> holder.label.text = context.getString(R.string.radio_user_radios)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
icon?.let {
|
||||
holder.native = true
|
||||
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
|
||||
val radio = getRadioAt(position)
|
||||
|
||||
holder.art.setImageDrawable(context.getDrawable(icon))
|
||||
holder.art.alpha = 0.7f
|
||||
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
holder.art.visibility = View.VISIBLE
|
||||
holder.name.text = radio.name
|
||||
holder.description.text = radio.description
|
||||
|
||||
context?.let { context ->
|
||||
val icon = when (radio.radio_type) {
|
||||
"actor_content" -> R.drawable.library
|
||||
"favorites" -> R.drawable.favorite
|
||||
"random" -> R.drawable.shuffle
|
||||
"less-listened" -> R.drawable.sad
|
||||
else -> null
|
||||
}
|
||||
|
||||
icon?.let {
|
||||
holder.native = true
|
||||
|
||||
holder.art.setImageDrawable(context.getDrawable(icon))
|
||||
holder.art.alpha = 0.7f
|
||||
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, private val listener: OnRadioClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
inner class ViewHolder(view: View, private val listener: OnRadioClickListener?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val label = view.label
|
||||
val art = view.art
|
||||
val name = view.name
|
||||
val description = view.description
|
||||
|
@ -66,7 +135,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
|
|||
var native = false
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(this, data[layoutPosition])
|
||||
listener?.onClick(this, getRadioAt(layoutPosition))
|
||||
}
|
||||
|
||||
fun spin() {
|
||||
|
@ -77,7 +146,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
|
|||
|
||||
art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
scope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted -> {
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.github.apognu.otter.adapters
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
|
@ -69,10 +71,6 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
return ResultType.Track.ordinal
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = when (viewType) {
|
||||
ResultType.Header.ordinal -> LayoutInflater.from(context).inflate(R.layout.row_search_header, parent, false)
|
||||
|
@ -93,27 +91,33 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
if (position == 0) {
|
||||
holder.title.text = context.getString(R.string.artists)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (artists.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + 1)) {
|
||||
holder.title.text = context.getString(R.string.albums)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (albums.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + albums.size + 2)) {
|
||||
holder.title.text = context.getString(R.string.tracks)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -160,6 +164,8 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
|
||||
holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
|
||||
if (resultType == ResultType.Track.ordinal) {
|
||||
(item as? Track)?.let { track ->
|
||||
context?.let { context ->
|
||||
|
@ -183,6 +189,23 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
}
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
holder.actions.setOnClickListener {
|
||||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.row_track)
|
||||
|
@ -191,6 +214,8 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
|
||||
R.id.track_add_to_playlist -> CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
|
@ -205,6 +230,15 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
|
|||
}
|
||||
}
|
||||
|
||||
fun getPositionOf(type: ResultType, position: Int): Int {
|
||||
return when (type) {
|
||||
ResultType.Artist -> position + 1
|
||||
ResultType.Album -> position + artists.size + 2
|
||||
ResultType.Track -> artists.size + albums.size + SECTION_COUNT + position
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
val handle = view.handle
|
||||
val cover = view.cover
|
||||
|
|
|
@ -2,23 +2,22 @@ package com.github.apognu.otter.adapters
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Build
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.fragments.FunkwhaleAdapter
|
||||
import com.github.apognu.otter.fragments.OtterAdapter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.row_track.view.*
|
||||
import java.util.*
|
||||
|
||||
class TracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : FunkwhaleAdapter<Track, TracksAdapter.ViewHolder>() {
|
||||
class TracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : OtterAdapter<Track, TracksAdapter.ViewHolder>() {
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
@ -27,11 +26,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
|
||||
var currentTrack: Track? = null
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
override fun getItemId(position: Int): Long = data[position].id.toLong()
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return data[position].id.toLong()
|
||||
}
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
|
@ -56,7 +53,7 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
val track = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album.cover.original))
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.cover)
|
||||
|
@ -64,19 +61,14 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
holder.title.text = track.title
|
||||
holder.artist.text = track.artist.name
|
||||
|
||||
Build.VERSION_CODES.P.onApi(
|
||||
{
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
|
||||
},
|
||||
{
|
||||
holder.title.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL)
|
||||
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
|
||||
})
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (track == currentTrack || track.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
|
@ -94,6 +86,23 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter = PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
holder.actions.setOnClickListener {
|
||||
|
@ -105,6 +114,8 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
|
||||
R.id.track_add_to_playlist -> CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
|
@ -135,14 +146,12 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
Collections.swap(data, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in newPosition.downTo(oldPosition)) {
|
||||
for (i in oldPosition.downTo(newPosition + 1)) {
|
||||
Collections.swap(data, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
notifyItemMoved(oldPosition, newPosition)
|
||||
|
||||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
|
||||
|
@ -169,6 +178,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
}
|
||||
|
||||
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
|
||||
var from = -1
|
||||
var to = -1
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
|
||||
override fun isItemViewSwipeEnabled() = false
|
||||
|
@ -177,7 +189,9 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
|
||||
to = target.adapterPosition
|
||||
|
||||
onItemMove(viewHolder.adapterPosition, to)
|
||||
|
||||
return true
|
||||
}
|
||||
|
@ -187,7 +201,10 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
context?.let {
|
||||
viewHolder?.itemView?.background = ColorDrawable(context.getColor(R.color.colorSelected))
|
||||
viewHolder?.let {
|
||||
from = viewHolder.adapterPosition
|
||||
viewHolder.itemView.background = ColorDrawable(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -195,6 +212,13 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
|
|||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
if (from != -1 && to != -1) {
|
||||
CommandBus.send(Command.MoveFromQueue(from, to))
|
||||
|
||||
from = -1
|
||||
to = -1
|
||||
}
|
||||
|
||||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
|
||||
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
|
|
|
@ -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,17 +1,16 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
|
@ -21,23 +20,22 @@ import com.github.apognu.otter.repositories.AlbumsRepository
|
|||
import com.github.apognu.otter.repositories.ArtistTracksRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.apognu.otter.views.LoadingFlotingActionButton
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_albums.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
||||
class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums
|
||||
override val recycler: RecyclerView get() = albums
|
||||
override val alwaysRefresh = false
|
||||
|
||||
lateinit var artistTracksRepository: ArtistTracksRepository
|
||||
private lateinit var artistTracksRepository: ArtistTracksRepository
|
||||
|
||||
var artistId = 0
|
||||
var artistName = ""
|
||||
|
@ -45,7 +43,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
|||
|
||||
companion object {
|
||||
fun new(artist: Artist, _art: String? = null): AlbumsFragment {
|
||||
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.albums[0].cover.original else ""
|
||||
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else ""
|
||||
|
||||
return AlbumsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
|
@ -56,7 +54,11 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
|||
}
|
||||
}
|
||||
|
||||
fun openTracks(context: Context?, album: Album, fragment: Fragment? = null) {
|
||||
fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) {
|
||||
if (album == null) {
|
||||
return
|
||||
}
|
||||
|
||||
(context as? MainActivity)?.let {
|
||||
fragment?.let { fragment ->
|
||||
fragment.onViewPager {
|
||||
|
@ -112,39 +114,24 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
|||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(cover)
|
||||
}
|
||||
|
||||
cover_background?.let { background ->
|
||||
activity?.let { activity ->
|
||||
GlobalScope.launch(IO) {
|
||||
val width = DisplayMetrics().apply {
|
||||
activity.windowManager.defaultDisplay.getMetrics(this)
|
||||
}.widthPixels
|
||||
|
||||
val backgroundCover = Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(artistArt))
|
||||
.get()
|
||||
.run { Bitmap.createScaledBitmap(this, width, width, false) }
|
||||
.run { Bitmap.createBitmap(this, 0, 0, width, background.height).toDrawable(resources) }
|
||||
.apply {
|
||||
alpha = 20
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
background.background = backgroundCover
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artist.text = artistName
|
||||
|
||||
play.setOnClickListener {
|
||||
val loaderAnimation = LoadingFlotingActionButton.start(play)
|
||||
val loader = CircularProgressDrawable(requireContext()).apply {
|
||||
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
loader.start()
|
||||
|
||||
play.icon = loader
|
||||
play.isClickable = false
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
artistTracksRepository.fetch(Repository.Origin.Network.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
|
@ -154,13 +141,32 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
|||
CommandBus.send(Command.ReplaceQueue(it))
|
||||
|
||||
withContext(Main) {
|
||||
LoadingFlotingActionButton.stop(play, loaderAnimation)
|
||||
play.icon = requireContext().getDrawable(R.drawable.play)
|
||||
play.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = cover.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
cover.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
cover.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
|
||||
override fun onClick(view: View?, album: Album) {
|
||||
openTracks(context, album, fragment = this@AlbumsFragment)
|
||||
|
|
|
@ -15,10 +15,11 @@ import com.github.apognu.otter.utils.Album
|
|||
import com.github.apognu.otter.utils.AppContext
|
||||
import kotlinx.android.synthetic.main.fragment_albums_grid.*
|
||||
|
||||
class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
|
||||
class AlbumsGridFragment : OtterFragment<Album, AlbumsGridAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums_grid
|
||||
override val recycler: RecyclerView get() = albums
|
||||
override val layoutManager get() = GridLayoutManager(context, 3)
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
|
@ -18,9 +18,10 @@ import com.github.apognu.otter.utils.Artist
|
|||
import com.github.apognu.otter.utils.onViewPager
|
||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||
|
||||
class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
|
||||
class ArtistsFragment : OtterFragment<Artist, ArtistsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_artists
|
||||
override val recycler: RecyclerView get() = artists
|
||||
override val alwaysRefresh = false
|
||||
|
||||
companion object {
|
||||
fun openAlbums(context: Context?, artist: Artist, fragment: Fragment? = null, art: String? = null) {
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.FavoritesAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.TracksRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import kotlinx.android.synthetic.main.fragment_favorites.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FavoritesFragment : FunkwhaleFragment<Track, FavoritesAdapter>() {
|
||||
class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
|
||||
override val viewRes = R.layout.fragment_favorites
|
||||
override val recycler: RecyclerView get() = favorites
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -28,11 +33,15 @@ class FavoritesFragment : FunkwhaleFragment<Track, FavoritesAdapter>() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(IO) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
withContext(Main) {
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
refreshDownloadedTracks()
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
|
@ -41,25 +50,59 @@ class FavoritesFragment : FunkwhaleFragment<Track, FavoritesAdapter>() {
|
|||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refreshCurrentTrack()
|
||||
is Event.RefreshTrack -> refreshCurrentTrack()
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack() {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
private suspend fun refreshDownloadedTracks() {
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Main) {
|
||||
adapter.data[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack?.current = false
|
||||
adapter.currentTrack = track.apply {
|
||||
current = true
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : FavoritesAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
(repository as? FavoritesRepository)?.let { repository ->
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.repositories.HttpUpstream
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
abstract class FunkwhaleAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
var data: MutableList<D> = mutableListOf()
|
||||
}
|
||||
|
||||
abstract class FunkwhaleFragment<D : Any, A : FunkwhaleAdapter<D, *>> : Fragment() {
|
||||
abstract val viewRes: Int
|
||||
abstract val recycler: RecyclerView
|
||||
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
|
||||
|
||||
lateinit var repository: Repository<D, *>
|
||||
lateinit var adapter: A
|
||||
|
||||
private var initialFetched = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(viewRes, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
recycler.layoutManager = layoutManager
|
||||
recycler.adapter = adapter
|
||||
|
||||
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
|
||||
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
|
||||
recycler.setOnScrollChangeListener { _, _, _, _, _ ->
|
||||
if (recycler.computeVerticalScrollOffset() > 0 && !recycler.canScrollVertically(1)) {
|
||||
fetch(Repository.Origin.Network.origin, adapter.data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetch(Repository.Origin.Cache.origin)
|
||||
|
||||
if (adapter.data.isEmpty()) {
|
||||
fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
swiper?.setOnRefreshListener {
|
||||
fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
}
|
||||
|
||||
open fun onDataFetched(data: List<D>) {}
|
||||
|
||||
private fun fetch(upstreams: Int = Repository.Origin.Network.origin, size: Int = 0) {
|
||||
var first = size == 0
|
||||
|
||||
if (upstreams == Repository.Origin.Network.origin) {
|
||||
swiper?.isRefreshing = true
|
||||
}
|
||||
|
||||
repository.fetch(upstreams, size).untilNetwork(IO) { data, isCache, hasMore ->
|
||||
GlobalScope.launch(Main) {
|
||||
if (isCache) {
|
||||
adapter.data = data.toMutableList()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (first && data.isNotEmpty()) {
|
||||
adapter.data.clear()
|
||||
}
|
||||
|
||||
onDataFetched(data)
|
||||
|
||||
adapter.data.addAll(data)
|
||||
|
||||
if (!hasMore) {
|
||||
swiper?.isRefreshing = false
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
if (adapter.data.isNotEmpty()) {
|
||||
try {
|
||||
repository.cacheId?.let { cacheId ->
|
||||
Cache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(repository.cache(adapter.data)).toByteArray()
|
||||
)
|
||||
}
|
||||
} catch (e: ConcurrentModificationException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (first) {
|
||||
true -> {
|
||||
adapter.notifyDataSetChanged()
|
||||
first = false
|
||||
}
|
||||
|
||||
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
|
@ -12,7 +13,6 @@ import com.github.apognu.otter.utils.*
|
|||
import kotlinx.android.synthetic.main.partial_queue.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.view.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -40,11 +40,25 @@ class LandscapeQueueFragment : Fragment() {
|
|||
queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
|
||||
queue_shuffle.setOnClickListener {
|
||||
CommandBus.send(Command.ShuffleQueue)
|
||||
}
|
||||
|
||||
queue_save.setOnClickListener {
|
||||
adapter?.data?.let {
|
||||
CommandBus.send(Command.AddToPlaylist(it))
|
||||
}
|
||||
}
|
||||
|
||||
queue_clear.setOnClickListener {
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
GlobalScope.launch(Main) {
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
|
@ -63,14 +77,20 @@ class LandscapeQueueFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refresh()
|
||||
is Event.RefreshTrack -> refresh()
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,26 +1,32 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.PlaylistTracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.ManagementPlaylistsRepository
|
||||
import com.github.apognu.otter.repositories.PlaylistTracksRepository
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
lateinit var playlistsRepository: ManagementPlaylistsRepository
|
||||
|
||||
var albumId = 0
|
||||
var albumArtist = ""
|
||||
|
@ -50,9 +56,10 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
adapter = PlaylistTracksAdapter(context, FavoriteListener())
|
||||
adapter = PlaylistTracksAdapter(context, FavoriteListener(), PlaylistListener())
|
||||
repository = PlaylistTracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
playlistsRepository = ManagementPlaylistsRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
@ -70,28 +77,60 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = covers.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
covers.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
covers.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
|
||||
context?.let { context ->
|
||||
actions.setOnClickListener {
|
||||
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.album)
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.add_to_queue -> {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data.map { it.track }))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>) {
|
||||
data.map { it.track.album }.toSet().map { it.cover.original }.take(4).forEachIndexed { index, url ->
|
||||
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> cover_top_left
|
||||
1 -> cover_top_right
|
||||
|
@ -100,10 +139,21 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
else -> cover_top_left
|
||||
}
|
||||
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
imageView?.let { view ->
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(url))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0, corner))
|
||||
.into(view)
|
||||
}
|
||||
}
|
||||
|
@ -111,22 +161,19 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refreshCurrentTrack()
|
||||
is Event.RefreshTrack -> refreshCurrentTrack()
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack() {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack = track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,4 +185,17 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
|
||||
override fun onMoveTrack(from: Int, to: Int) {
|
||||
playlistsRepository.move(albumId, from, to)
|
||||
}
|
||||
|
||||
override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
|
||||
lifecycleScope.launch(Main) {
|
||||
playlistsRepository.remove(albumId, track, index)
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,6 +6,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
|
@ -18,7 +19,6 @@ import kotlinx.android.synthetic.main.fragment_queue.view.*
|
|||
import kotlinx.android.synthetic.main.partial_queue.*
|
||||
import kotlinx.android.synthetic.main.partial_queue.view.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -62,11 +62,25 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
included.queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
|
||||
queue_shuffle.setOnClickListener {
|
||||
CommandBus.send(Command.ShuffleQueue)
|
||||
}
|
||||
|
||||
queue_save.setOnClickListener {
|
||||
adapter?.data?.let {
|
||||
CommandBus.send(Command.AddToPlaylist(it))
|
||||
}
|
||||
}
|
||||
|
||||
queue_clear.setOnClickListener {
|
||||
CommandBus.send(Command.ClearQueue)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
included?.let { included ->
|
||||
adapter?.let {
|
||||
|
@ -87,15 +101,21 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refresh()
|
||||
is Event.RefreshTrack -> refresh()
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.github.apognu.otter.fragments
|
|||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.RadiosAdapter
|
||||
|
@ -9,18 +10,18 @@ import com.github.apognu.otter.repositories.RadiosRepository
|
|||
import com.github.apognu.otter.utils.*
|
||||
import kotlinx.android.synthetic.main.fragment_radios.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
|
||||
class RadiosFragment : OtterFragment<Radio, RadiosAdapter>() {
|
||||
override val viewRes = R.layout.fragment_radios
|
||||
override val recycler: RecyclerView get() = radios
|
||||
override val alwaysRefresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = RadiosAdapter(context, RadioClickListener())
|
||||
adapter = RadiosAdapter(context, lifecycleScope, RadioClickListener())
|
||||
repository = RadiosRepository(context)
|
||||
}
|
||||
|
||||
|
@ -34,15 +35,16 @@ class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
|
|||
|
||||
CommandBus.send(Command.PlayRadio(radio))
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted ->
|
||||
if (radios != null) { recycler.forEach {
|
||||
it.isEnabled = true
|
||||
it.isClickable = true
|
||||
if (radios != null) {
|
||||
recycler.forEach {
|
||||
it.isEnabled = true
|
||||
it.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,8 +22,10 @@ class TrackInfoDetailsFragment : DialogFragment() {
|
|||
return TrackInfoDetailsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"artistName" to track.artist.name,
|
||||
"albumTitle" to track.album.title,
|
||||
"albumTitle" to track.album?.title,
|
||||
"trackTitle" to track.title,
|
||||
"trackCopyright" to track.copyright,
|
||||
"trackLicense" to track.license,
|
||||
"trackPosition" to track.position,
|
||||
"trackDuration" to track.bestUpload()?.duration?.toLong()?.let { toDurationString(it, showSeconds = true) },
|
||||
"trackBitrate" to track.bestUpload()?.bitrate?.let { "${it / 1000} Kbps" },
|
||||
|
@ -48,8 +50,10 @@ class TrackInfoDetailsFragment : DialogFragment() {
|
|||
properties.add(Pair(R.string.track_info_details_artist, getString("artistName")))
|
||||
properties.add(Pair(R.string.track_info_details_album, getString("albumTitle")))
|
||||
properties.add(Pair(R.string.track_info_details_track_title, getString("trackTitle")))
|
||||
properties.add(Pair(R.string.track_info_details_track_copyright, getString("trackCopyright")))
|
||||
properties.add(Pair(R.string.track_info_details_track_license, getString("trackLicense")))
|
||||
properties.add(Pair(R.string.track_info_details_track_duration, getString("trackDuration")))
|
||||
properties.add(Pair(R.string.track_info_details_track_position, getString("trackPosition")))
|
||||
properties.add(Pair(R.string.track_info_details_track_position, getInt("trackPosition").toString()))
|
||||
properties.add(Pair(R.string.track_info_details_track_bitrate, getString("trackBitrate")))
|
||||
properties.add(Pair(R.string.track_info_details_track_instance, getString("trackInstance")))
|
||||
}
|
||||
|
|
|
@ -1,26 +1,36 @@
|
|||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritedRepository
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.TracksRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
||||
class TracksFragment : OtterFragment<Track, TracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
lateinit var favoritedRepository: FavoritedRepository
|
||||
|
||||
private var albumId = 0
|
||||
private var albumArtist = ""
|
||||
|
@ -34,7 +44,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
"albumId" to album.id,
|
||||
"albumArtist" to album.artist.name,
|
||||
"albumTitle" to album.title,
|
||||
"albumCover" to album.cover.original
|
||||
"albumCover" to album.cover()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +63,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
adapter = TracksAdapter(context, FavoriteListener())
|
||||
repository = TracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
@ -65,6 +76,7 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(cover)
|
||||
|
||||
artist.text = albumArtist
|
||||
|
@ -74,44 +86,136 @@ class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
refreshDownloadedTracks()
|
||||
}
|
||||
|
||||
var coverHeight: Float? = null
|
||||
|
||||
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
|
||||
if (coverHeight == null) {
|
||||
coverHeight = cover.measuredHeight.toFloat()
|
||||
}
|
||||
|
||||
cover.translationY = (scrollY / 2).toFloat()
|
||||
|
||||
coverHeight?.let { height ->
|
||||
cover.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> play.text = getString(R.string.playback_play)
|
||||
else -> play.text = getString(R.string.playback_shuffle)
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
}
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data))
|
||||
context?.let { context ->
|
||||
actions.setOnClickListener {
|
||||
PopupMenu(context, actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
inflate(R.menu.album)
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.play_secondary)?.let { item ->
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> item.title = getString(R.string.playback_shuffle)
|
||||
else -> item.title = getString(R.string.playback_play)
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refreshCurrentTrack()
|
||||
is Event.RefreshTrack -> refreshCurrentTrack()
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.play_secondary -> when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
}
|
||||
|
||||
R.id.add_to_queue -> {
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.AddToQueue(adapter.data))
|
||||
else -> CommandBus.send(Command.AddToQueue(adapter.data.shuffled()))
|
||||
}
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack() {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(IO) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTracks() {
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Main) {
|
||||
adapter.data[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
adapter.currentTrack?.current = false
|
||||
adapter.currentTrack = track.apply {
|
||||
current = true
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
|
|
|
@ -3,29 +3,27 @@ package com.github.apognu.otter.playback
|
|||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaMetadata
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.maybeNormalizeUrl
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaControlsManager(val context: Service, private val mediaSession: MediaSessionCompat) {
|
||||
class MediaControlsManager(val context: Service, private val scope: CoroutineScope, private val mediaSession: MediaSessionCompat) {
|
||||
companion object {
|
||||
const val NOTIFICATION_ACTION_OPEN_QUEUE = 0
|
||||
const val NOTIFICATION_ACTION_PREVIOUS = 1
|
||||
const val NOTIFICATION_ACTION_TOGGLE = 2
|
||||
const val NOTIFICATION_ACTION_NEXT = 3
|
||||
}
|
||||
|
||||
private var notification: Notification? = null
|
||||
|
@ -39,22 +37,11 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
|
|||
false -> R.drawable.play
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(Default) {
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
|
||||
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
|
||||
|
||||
val coverUrl = maybeNormalizeUrl(track.album.cover.original)
|
||||
val cover = coverUrl?.run { Picasso.get().load(coverUrl) }
|
||||
|
||||
mediaSession.setMetadata(MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadata.METADATA_KEY_ARTIST, track.artist.name)
|
||||
putString(MediaMetadata.METADATA_KEY_TITLE, track.title)
|
||||
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
|
||||
|
||||
cover?.let {
|
||||
try { putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it.get()) } catch (_: Exception) {}
|
||||
}
|
||||
}.build())
|
||||
val coverUrl = maybeNormalizeUrl(track.album?.cover())
|
||||
|
||||
notification = NotificationCompat.Builder(
|
||||
context,
|
||||
|
@ -67,13 +54,18 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
|
|||
.setMediaSession(mediaSession.sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ottericon)
|
||||
.setSmallIcon(R.drawable.ottershape)
|
||||
.run {
|
||||
if (cover != null) {
|
||||
try { setLargeIcon(cover.get()) } catch (_: Exception) {}
|
||||
coverUrl?.let {
|
||||
try {
|
||||
setLargeIcon(Picasso.get().load(coverUrl).get())
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
this
|
||||
} else this
|
||||
return@run this
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
.setContentTitle(track.title)
|
||||
.setContentText(track.artist.name)
|
||||
|
@ -82,58 +74,43 @@ class MediaControlsManager(val context: Service, private val mediaSession: Media
|
|||
.addAction(
|
||||
action(
|
||||
R.drawable.previous, context.getString(R.string.control_previous),
|
||||
NOTIFICATION_ACTION_PREVIOUS
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
action(
|
||||
stateIcon, context.getString(R.string.control_toggle),
|
||||
NOTIFICATION_ACTION_TOGGLE
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
action(
|
||||
R.drawable.next, context.getString(R.string.control_next),
|
||||
NOTIFICATION_ACTION_NEXT
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
notification?.let {
|
||||
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
if (playing) {
|
||||
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
}
|
||||
}
|
||||
|
||||
if (playing) tick()
|
||||
Otter.get().mediaSession.connector.invalidateMediaSessionMetadata()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
notification?.let {
|
||||
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
}
|
||||
fun remove() {
|
||||
NotificationManagerCompat.from(context).cancel(AppContext.NOTIFICATION_MEDIA_CONTROL)
|
||||
}
|
||||
|
||||
private fun action(icon: Int, title: String, id: Int): NotificationCompat.Action {
|
||||
val intent = Intent(context, MediaControlActionReceiver::class.java).apply { action = id.toString() }
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, 0)
|
||||
|
||||
return NotificationCompat.Action.Builder(icon, title, pendingIntent).build()
|
||||
}
|
||||
}
|
||||
|
||||
class MediaControlActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> CommandBus.send(
|
||||
Command.PreviousTrack
|
||||
)
|
||||
MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> CommandBus.send(
|
||||
Command.ToggleState
|
||||
)
|
||||
MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> CommandBus.send(
|
||||
Command.NextTrack
|
||||
)
|
||||
private fun action(icon: Int, title: String, id: Long): NotificationCompat.Action {
|
||||
return MediaButtonReceiver.buildMediaButtonPendingIntent(context, id).run {
|
||||
NotificationCompat.Action.Builder(icon, title, this).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,37 +8,47 @@ import android.content.IntentFilter
|
|||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlayerService : Service() {
|
||||
private lateinit var queue: QueueManager
|
||||
private val jobs = mutableListOf<Job>()
|
||||
companion object {
|
||||
const val INITIAL_COMMAND_KEY = "start_command"
|
||||
}
|
||||
|
||||
private var started = false
|
||||
private val scope: CoroutineScope = CoroutineScope(Job() + Main)
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioFocusChangeListener = AudioFocusChange()
|
||||
private var stateWhenLostFocus = false
|
||||
|
||||
private lateinit var queue: QueueManager
|
||||
private lateinit var mediaControlsManager: MediaControlsManager
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var player: SimpleExoPlayer
|
||||
|
||||
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
|
||||
|
||||
private lateinit var playerEventListener: PlayerEventListener
|
||||
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
|
||||
|
||||
|
@ -47,20 +57,39 @@ class PlayerService : Service() {
|
|||
private lateinit var radioPlayer: RadioPlayer
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
watchEventBus()
|
||||
intent?.action?.let {
|
||||
if (it == Intent.ACTION_MEDIA_BUTTON) {
|
||||
intent.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
|
||||
when (key.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
if (hasAudioFocus(true)) MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
|
||||
Unit
|
||||
}
|
||||
else -> MediaButtonReceiver.handleIntent(Otter.get().mediaSession.session, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
started = true
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
queue = QueueManager(this)
|
||||
radioPlayer = RadioPlayer(this)
|
||||
radioPlayer = RadioPlayer(this, scope)
|
||||
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
|
||||
setAudioAttributes(AudioAttributes.Builder().run {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
|
@ -76,45 +105,33 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply {
|
||||
isActive = true
|
||||
}
|
||||
mediaControlsManager = MediaControlsManager(this, scope, Otter.get().mediaSession.session)
|
||||
|
||||
mediaControlsManager = MediaControlsManager(this, mediaSession)
|
||||
|
||||
player = ExoPlayerFactory.newSimpleInstance(this).apply {
|
||||
player = SimpleExoPlayer.Builder(this).build().apply {
|
||||
playWhenReady = false
|
||||
|
||||
playerEventListener = PlayerEventListener().also {
|
||||
addListener(it)
|
||||
}
|
||||
}
|
||||
|
||||
MediaSessionConnector(mediaSession).also {
|
||||
it.setPlayer(this)
|
||||
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
|
||||
mediaButtonEvent?.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
|
||||
if (key.action == KeyEvent.ACTION_UP) {
|
||||
when (key.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
Otter.get().mediaSession.active = true
|
||||
|
||||
true
|
||||
}
|
||||
Otter.get().mediaSession.connector.apply {
|
||||
setPlayer(player)
|
||||
|
||||
setMediaMetadataProvider {
|
||||
buildTrackMetadata(queue.current())
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.current > -1) {
|
||||
player.prepare(queue.datasources, true, true)
|
||||
player.prepare(queue.datasources)
|
||||
|
||||
Cache.get(this, "progress")?.let { progress ->
|
||||
player.seekTo(queue.current, progress.readLine().toLong())
|
||||
|
||||
val (current, duration, percent) = progress(true)
|
||||
val (current, duration, percent) = getProgress(true)
|
||||
|
||||
ProgressBus.send(current, duration, percent)
|
||||
}
|
||||
|
@ -124,77 +141,68 @@ class PlayerService : Service() {
|
|||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
for (message in CommandBus.get()) {
|
||||
when (message) {
|
||||
scope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshService -> {
|
||||
EventBus.send(Event.QueueChanged)
|
||||
|
||||
if (queue.metadata.isNotEmpty()) {
|
||||
EventBus.send(Event.RefreshTrack(queue.current(), player.playWhenReady))
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
EventBus.send(Event.StateChanged(player.playWhenReady))
|
||||
}
|
||||
}
|
||||
|
||||
is Command.ReplaceQueue -> {
|
||||
if (!message.fromRadio) radioPlayer.stop()
|
||||
if (!command.fromRadio) radioPlayer.stop()
|
||||
|
||||
queue.replace(message.queue)
|
||||
queue.replace(command.queue)
|
||||
player.prepare(queue.datasources, true, true)
|
||||
|
||||
state(true)
|
||||
setPlaybackState(true)
|
||||
|
||||
EventBus.send(
|
||||
Event.RefreshTrack(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.AddToQueue -> queue.append(message.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(message.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(message.track)
|
||||
is Command.MoveFromQueue -> queue.move(message.oldPosition, message.newPosition)
|
||||
is Command.AddToQueue -> queue.append(command.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(command.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(command.track)
|
||||
is Command.MoveFromQueue -> queue.move(command.oldPosition, command.newPosition)
|
||||
|
||||
is Command.PlayTrack -> {
|
||||
queue.current = message.index
|
||||
player.seekTo(message.index, C.TIME_UNSET)
|
||||
queue.current = command.index
|
||||
player.seekTo(command.index, C.TIME_UNSET)
|
||||
|
||||
state(true)
|
||||
setPlaybackState(true)
|
||||
|
||||
EventBus.send(Event.RefreshTrack(queue.current(), true))
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.ToggleState -> toggle()
|
||||
is Command.SetState -> state(message.state)
|
||||
is Command.ToggleState -> togglePlayback()
|
||||
is Command.SetState -> setPlaybackState(command.state)
|
||||
|
||||
is Command.NextTrack -> {
|
||||
player.next()
|
||||
is Command.NextTrack -> skipToNextTrack()
|
||||
is Command.PreviousTrack -> skipToPreviousTrack()
|
||||
is Command.Seek -> seek(command.progress)
|
||||
|
||||
Cache.set(this@PlayerService, "progress", "0".toByteArray())
|
||||
ProgressBus.send(0, 0, 0)
|
||||
is Command.ClearQueue -> {
|
||||
queue.clear()
|
||||
player.stop()
|
||||
}
|
||||
is Command.PreviousTrack -> previousTrack()
|
||||
is Command.Seek -> progress(message.progress)
|
||||
|
||||
is Command.ClearQueue -> queue.clear()
|
||||
is Command.ShuffleQueue -> queue.shuffle()
|
||||
|
||||
is Command.PlayRadio -> {
|
||||
queue.clear()
|
||||
radioPlayer.play(message.radio)
|
||||
radioPlayer.play(command.radio)
|
||||
}
|
||||
|
||||
is Command.SetRepeatMode -> player.repeatMode = message.mode
|
||||
}
|
||||
is Command.SetRepeatMode -> player.repeatMode = command.mode
|
||||
|
||||
if (player.playWhenReady) {
|
||||
mediaControlsManager.tick()
|
||||
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
|
||||
is Command.PinTracks -> command.tracks.forEach { PinService.download(this@PlayerService, it) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
scope.launch(Main) {
|
||||
RequestBus.get().collect { request ->
|
||||
when (request) {
|
||||
is Request.GetCurrentTrack -> request.channel?.offer(Response.CurrentTrack(queue.current()))
|
||||
|
@ -202,26 +210,35 @@ class PlayerService : Service() {
|
|||
is Request.GetQueue -> request.channel?.offer(Response.Queue(queue.get()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
scope.launch(Main) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
|
||||
val (current, duration, percent) = progress()
|
||||
val (current, duration, percent) = getProgress()
|
||||
|
||||
if (player.playWhenReady) {
|
||||
ProgressBus.send(current, duration, percent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
|
||||
if (!player.playWhenReady) {
|
||||
NotificationManagerCompat.from(this).cancelAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onDestroy() {
|
||||
jobs.forEach { it.cancel() }
|
||||
scope.cancel()
|
||||
|
||||
try {
|
||||
unregisterReceiver(headphonesUnpluggedReceiver)
|
||||
|
@ -239,25 +256,18 @@ class PlayerService : Service() {
|
|||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
})
|
||||
|
||||
mediaSession.isActive = false
|
||||
mediaSession.release()
|
||||
|
||||
player.removeListener(playerEventListener)
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
player.release()
|
||||
|
||||
queue.cache.release()
|
||||
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
Otter.get().mediaSession.active = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun state(state: Boolean) {
|
||||
private fun setPlaybackState(state: Boolean) {
|
||||
if (!state) {
|
||||
val (progress, _, _) = progress()
|
||||
val (progress, _, _) = getProgress()
|
||||
|
||||
Cache.set(this@PlayerService, "progress", progress.toString().toByteArray())
|
||||
}
|
||||
|
@ -266,6 +276,76 @@ class PlayerService : Service() {
|
|||
player.prepare(queue.datasources)
|
||||
}
|
||||
|
||||
if (hasAudioFocus(state)) {
|
||||
player.playWhenReady = state
|
||||
|
||||
EventBus.send(Event.StateChanged(state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
setPlaybackState(!player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun skipToPreviousTrack() {
|
||||
if (player.currentPosition > 5000) {
|
||||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
}
|
||||
|
||||
private fun skipToNextTrack() {
|
||||
player.next()
|
||||
|
||||
Cache.set(this@PlayerService, "progress", "0".toByteArray())
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun getProgress(force: Boolean = false): Triple<Int, Int, Int> {
|
||||
if (!player.playWhenReady && !force) return progressCache
|
||||
|
||||
return queue.current()?.bestUpload()?.let { upload ->
|
||||
val current = player.currentPosition
|
||||
val duration = upload.duration.toFloat()
|
||||
val percent = ((current / (duration * 1000)) * 100).toInt()
|
||||
|
||||
progressCache = Triple(current.toInt(), duration.toInt(), percent)
|
||||
progressCache
|
||||
} ?: Triple(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun seek(value: Int) {
|
||||
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
|
||||
|
||||
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
|
||||
|
||||
player.seekTo(duration.toLong())
|
||||
}
|
||||
|
||||
private fun buildTrackMetadata(track: Track?): MediaMetadataCompat {
|
||||
track?.let {
|
||||
val coverUrl = maybeNormalizeUrl(track.album?.cover())
|
||||
|
||||
return mediaMetadataBuilder.apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
|
||||
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
|
||||
|
||||
try {
|
||||
runBlocking(IO) {
|
||||
this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
return mediaMetadataBuilder.build()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun hasAudioFocus(state: Boolean): Boolean {
|
||||
var allowed = !state
|
||||
|
||||
if (!allowed) {
|
||||
|
@ -291,46 +371,10 @@ class PlayerService : Service() {
|
|||
)
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
player.playWhenReady = state
|
||||
|
||||
EventBus.send(Event.StateChanged(state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
state(!player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun previousTrack() {
|
||||
if (player.currentPosition > 5000) {
|
||||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
}
|
||||
|
||||
private fun progress(force: Boolean = false): Triple<Int, Int, Int> {
|
||||
if (!player.playWhenReady && !force) return progressCache
|
||||
|
||||
return queue.current()?.bestUpload()?.let { upload ->
|
||||
val current = player.currentPosition
|
||||
val duration = upload.duration.toFloat()
|
||||
val percent = ((current / (duration * 1000)) * 100).toInt()
|
||||
|
||||
progressCache = Triple(current.toInt(), duration.toInt(), percent)
|
||||
progressCache
|
||||
} ?: Triple(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun progress(value: Int) {
|
||||
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
|
||||
|
||||
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
|
||||
|
||||
player.seekTo(duration.toLong())
|
||||
return allowed
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
inner class PlayerEventListener : Player.EventListener {
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
super.onPlayerStateChanged(playWhenReady, playbackState)
|
||||
|
@ -338,7 +382,7 @@ class PlayerService : Service() {
|
|||
EventBus.send(Event.StateChanged(playWhenReady))
|
||||
|
||||
if (queue.current == -1) {
|
||||
EventBus.send(Event.TrackPlayed(queue.current(), playWhenReady))
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
when (playWhenReady) {
|
||||
|
@ -346,33 +390,51 @@ class PlayerService : Service() {
|
|||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
|
||||
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
|
||||
Player.STATE_IDLE -> state(false)
|
||||
Player.STATE_ENDED -> EventBus.send(Event.PlaybackStopped)
|
||||
Player.STATE_ENDED -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
queue.current = 0
|
||||
player.seekTo(0, C.TIME_UNSET)
|
||||
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
Player.STATE_IDLE -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
return EventBus.send(Event.PlaybackStopped)
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
|
||||
}
|
||||
|
||||
false -> {
|
||||
EventBus.send(Event.StateChanged(false))
|
||||
EventBus.send(Event.Buffering(false))
|
||||
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
mediaControlsManager.updateNotification(queue.current(), false)
|
||||
stopForeground(false)
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
|
||||
Player.STATE_IDLE -> mediaControlsManager.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {
|
||||
override fun onTracksChanged(trackGroups: TrackGroupArray, trackSelections: TrackSelectionArray) {
|
||||
super.onTracksChanged(trackGroups, trackSelections)
|
||||
|
||||
queue.current = player.currentWindowIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
|
||||
if (queue.current != player.currentWindowIndex) {
|
||||
queue.current = player.currentWindowIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
|
||||
}
|
||||
|
||||
if (queue.get().isNotEmpty() && queue.current() == queue.get().last() && radioPlayer.isActive()) {
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(IO) {
|
||||
if (radioPlayer.lock.tryAcquire()) {
|
||||
radioPlayer.prepareNextTrack()
|
||||
radioPlayer.lock.release()
|
||||
|
@ -382,7 +444,7 @@ class PlayerService : Service() {
|
|||
|
||||
Cache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
|
||||
|
||||
EventBus.send(Event.RefreshTrack(queue.current(), true))
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(reason: Int) {
|
||||
|
@ -393,12 +455,15 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: ExoPlaybackException?) {
|
||||
override fun onPlayerError(error: ExoPlaybackException) {
|
||||
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
|
||||
|
||||
queue.current()?.let {
|
||||
queue.remove(it)
|
||||
player.prepare(queue.datasources)
|
||||
if (player.playWhenReady) {
|
||||
queue.current++
|
||||
player.prepare(queue.datasources, true, true)
|
||||
player.seekTo(queue.current, 0)
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -409,18 +474,18 @@ class PlayerService : Service() {
|
|||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
player.volume = 1f
|
||||
|
||||
state(stateWhenLostFocus)
|
||||
setPlaybackState(stateWhenLostFocus)
|
||||
stateWhenLostFocus = false
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
stateWhenLostFocus = false
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
stateWhenLostFocus = player.playWhenReady
|
||||
state(false)
|
||||
setPlaybackState(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
|
@ -430,4 +495,4 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,38 +2,53 @@ package com.github.apognu.otter.playback
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.FileDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
|
||||
class QueueManager(val context: Context) {
|
||||
var cache: SimpleCache
|
||||
var metadata: MutableList<Track> = mutableListOf()
|
||||
val datasources = ConcatenatingMediaSource()
|
||||
var current = -1
|
||||
|
||||
init {
|
||||
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().also {
|
||||
cache = SimpleCache(
|
||||
context.cacheDir.resolve("media"),
|
||||
LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024)
|
||||
companion object {
|
||||
fun factory(context: Context): CacheDataSourceFactory {
|
||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
||||
defaultRequestProperties.apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
set("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val playbackCache = CacheDataSourceFactory(Otter.get().exoCache, http)
|
||||
|
||||
return CacheDataSourceFactory(
|
||||
Otter.get().exoDownloadCache,
|
||||
playbackCache,
|
||||
FileDataSource.Factory(),
|
||||
null,
|
||||
CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Cache.get(context, "queue")?.let { json ->
|
||||
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
|
||||
metadata = cache.data.toMutableList()
|
||||
|
||||
val factory = factory()
|
||||
val factory = factory(context)
|
||||
|
||||
datasources.addMediaSources(metadata.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
@ -56,20 +71,8 @@ class QueueManager(val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
private fun factory(): CacheDataSourceFactory {
|
||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
||||
defaultRequestProperties.apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
set("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CacheDataSourceFactory(cache, http)
|
||||
}
|
||||
|
||||
fun replace(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
val factory = factory(context)
|
||||
|
||||
val sources = tracks.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
@ -87,7 +90,7 @@ class QueueManager(val context: Context) {
|
|||
}
|
||||
|
||||
fun append(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
val factory = factory(context)
|
||||
val missingTracks = tracks.filter { metadata.indexOf(it) == -1 }
|
||||
|
||||
val sources = missingTracks.map { track ->
|
||||
|
@ -105,7 +108,7 @@ class QueueManager(val context: Context) {
|
|||
}
|
||||
|
||||
fun insertNext(track: Track) {
|
||||
val factory = factory()
|
||||
val factory = factory(context)
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
if (metadata.indexOf(track) == -1) {
|
||||
|
@ -124,8 +127,24 @@ class QueueManager(val context: Context) {
|
|||
|
||||
fun remove(track: Track) {
|
||||
metadata.indexOf(track).let {
|
||||
if (it < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
datasources.removeMediaSource(it)
|
||||
metadata.removeAt(it)
|
||||
|
||||
if (it == current) {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
if (it < current) {
|
||||
current--
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.isEmpty()) {
|
||||
current = -1
|
||||
}
|
||||
|
||||
persist()
|
||||
|
@ -159,5 +178,34 @@ class QueueManager(val context: Context) {
|
|||
metadata = mutableListOf()
|
||||
datasources.clear()
|
||||
current = -1
|
||||
|
||||
persist()
|
||||
}
|
||||
|
||||
fun shuffle() {
|
||||
if (metadata.size < 2) return
|
||||
|
||||
if (current == -1) {
|
||||
replace(metadata.shuffled())
|
||||
} else {
|
||||
move(current, 0)
|
||||
current = 0
|
||||
|
||||
val shuffled =
|
||||
metadata
|
||||
.drop(1)
|
||||
.shuffled()
|
||||
|
||||
while (metadata.size > 1) {
|
||||
datasources.removeMediaSource(metadata.size - 1)
|
||||
metadata.removeAt(metadata.size - 1)
|
||||
}
|
||||
|
||||
append(shuffled)
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
}
|
|
@ -6,29 +6,31 @@ import com.github.apognu.otter.repositories.FavoritedRepository
|
|||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null)
|
||||
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null, var related_object_id: String? = null)
|
||||
data class RadioSession(val id: Int)
|
||||
data class RadioTrackBody(val session: Int)
|
||||
data class RadioTrack(val position: Int, val track: RadioTrackID)
|
||||
data class RadioTrackID(val id: Int)
|
||||
|
||||
class RadioPlayer(val context: Context) {
|
||||
class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
||||
val lock = Semaphore(1)
|
||||
|
||||
private var currentRadio: Radio? = null
|
||||
private var session: Int? = null
|
||||
private var cookie: String? = null
|
||||
|
||||
private val favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
|
@ -36,8 +38,11 @@ class RadioPlayer(val context: Context) {
|
|||
Cache.get(context, "radio_type")?.readLine()?.let { radio_type ->
|
||||
Cache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
|
||||
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
|
||||
val cachedCookie = Cache.get(context, "radio_cookie")?.readLine()
|
||||
|
||||
currentRadio = Radio(radio_id, radio_type, "", "")
|
||||
session = radio_session
|
||||
cookie = cachedCookie
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +52,7 @@ class RadioPlayer(val context: Context) {
|
|||
currentRadio = radio
|
||||
session = null
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(IO) {
|
||||
createSession()
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +64,7 @@ class RadioPlayer(val context: Context) {
|
|||
Cache.delete(context, "radio_type")
|
||||
Cache.delete(context, "radio_id")
|
||||
Cache.delete(context, "radio_session")
|
||||
Cache.delete(context, "radio_cookie")
|
||||
}
|
||||
|
||||
fun isActive() = currentRadio != null && session != null
|
||||
|
@ -66,24 +72,26 @@ class RadioPlayer(val context: Context) {
|
|||
private suspend fun createSession() {
|
||||
currentRadio?.let { radio ->
|
||||
try {
|
||||
val request = RadioSessionBody(radio.radio_type).apply {
|
||||
val request = RadioSessionBody(radio.radio_type, related_object_id = radio.related_object_id).apply {
|
||||
if (radio_type == "custom") {
|
||||
custom_radio = radio.id
|
||||
}
|
||||
}
|
||||
|
||||
val body = Gson().toJson(request)
|
||||
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
|
||||
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.awaitObjectResult(gsonDeserializerOf(RadioSession::class.java))
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
|
||||
|
||||
session = result.get().id
|
||||
cookie = response.header("set-cookie").joinToString(";")
|
||||
|
||||
Cache.set(context, "radio_type", radio.radio_type.toByteArray())
|
||||
Cache.set(context, "radio_id", radio.id.toString().toByteArray())
|
||||
Cache.set(context, "radio_session", session.toString().toByteArray())
|
||||
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
|
||||
|
||||
prepareNextTrack(true)
|
||||
} catch (e: Exception) {
|
||||
|
@ -101,6 +109,11 @@ class RadioPlayer(val context: Context) {
|
|||
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
|
||||
.authorize()
|
||||
.header("Content-Type", "application/json")
|
||||
.apply {
|
||||
cookie?.let {
|
||||
header("cookie", it)
|
||||
}
|
||||
}
|
||||
.body(body)
|
||||
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
|
||||
|
||||
|
@ -108,7 +121,7 @@ class RadioPlayer(val context: Context) {
|
|||
.authorize()
|
||||
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
|
||||
|
||||
val favorites = favoritedRepository.fetch(Repository.Origin.Network.origin)
|
||||
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.OtterResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
|
@ -11,7 +11,7 @@ import java.io.BufferedReader
|
|||
|
||||
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "tracks-artist-$artistId"
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
|
|
@ -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,27 +1,43 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "favorites.v2"
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Track>) = data.map {
|
||||
it.favorite = true
|
||||
it
|
||||
private val favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
track.favorite = true
|
||||
track.downloaded = downloaded.contains(track.id)
|
||||
|
||||
track.bestUpload()?.let { upload ->
|
||||
maybeNormalizeUrl(upload.listen_url)?.let { url ->
|
||||
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
fun addFavorite(id: Int) {
|
||||
|
@ -33,11 +49,13 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
|||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(IO) {
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
|
||||
favoritedRepository.update(context, scope)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,19 +68,27 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
|||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
scope.launch(IO) {
|
||||
request
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
|
||||
favoritedRepository.update(context, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
|
||||
override val cacheId = "favorited"
|
||||
override val upstream = HttpUpstream<Int, FunkwhaleResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Int>) = FavoritedCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
|
||||
|
||||
fun update(context: Context?, scope: CoroutineScope) {
|
||||
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
|
||||
Cache.set(context, cacheId, Gson().toJson(cache(favorites)).toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import java.io.Reader
|
|||
import java.lang.reflect.Type
|
||||
import kotlin.math.ceil
|
||||
|
||||
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
|
||||
class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
|
||||
enum class Behavior {
|
||||
Single, AtOnce, Progressive
|
||||
}
|
||||
|
@ -28,68 +28,80 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, pr
|
|||
|
||||
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
|
||||
|
||||
val offsetUrl =
|
||||
val url =
|
||||
Uri.parse(url)
|
||||
.buildUpon()
|
||||
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
|
||||
.appendQueryParameter("page", page.toString())
|
||||
.appendQueryParameter("scope", Settings.getScopes().joinToString(","))
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
get(offsetUrl).fold(
|
||||
get(url).fold(
|
||||
{ response ->
|
||||
val data = response.getData()
|
||||
|
||||
if (behavior == Behavior.Progressive || response.next == null) {
|
||||
emit(Repository.Response(Repository.Origin.Network, data, false))
|
||||
} else {
|
||||
emit(Repository.Response(Repository.Origin.Network, data, true))
|
||||
when (behavior) {
|
||||
Behavior.Single -> emit(Repository.Response(Repository.Origin.Network, data, page, false))
|
||||
Behavior.Progressive -> emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
|
||||
|
||||
fetch(size + data.size).collect { emit(it) }
|
||||
else -> {
|
||||
emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
|
||||
|
||||
if (response.next != null) fetch(size + data.size).collect { emit(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
when (error.exception) {
|
||||
is RefreshError -> EventBus.send(Event.LogOut)
|
||||
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), false))
|
||||
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
|
||||
}
|
||||
}
|
||||
)
|
||||
}.flowOn(IO)
|
||||
|
||||
class GenericDeserializer<T : FunkwhaleResponse<*>>(val type: Type) : ResponseDeserializable<T> {
|
||||
class GenericDeserializer<T : OtterResponse<*>>(val type: Type) : ResponseDeserializable<T> {
|
||||
override fun deserialize(reader: Reader): T? {
|
||||
return Gson().fromJson(reader, type)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(url: String): Result<R, FuelError> {
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
||||
return if (HTTP.refresh()) {
|
||||
return try {
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
request.awaitObjectResult(GenericDeserializer(type))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Result.error(FuelError.wrap(e))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
||||
return try {
|
||||
return if (HTTP.refresh()) {
|
||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
||||
}
|
||||
}
|
||||
|
||||
request.awaitObjectResult(GenericDeserializer(type))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.error(FuelError.wrap(e))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.OtterResponse
|
||||
import com.github.apognu.otter.utils.PlaylistTrack
|
||||
import com.github.apognu.otter.utils.PlaylistTracksCache
|
||||
import com.github.apognu.otter.utils.PlaylistTracksResponse
|
||||
|
@ -14,7 +14,7 @@ import java.io.BufferedReader
|
|||
|
||||
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
|
||||
override val cacheId = "tracks-playlist-$playlistId"
|
||||
override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.OtterResponse
|
||||
import com.github.apognu.otter.utils.Radio
|
||||
import com.github.apognu.otter.utils.RadiosCache
|
||||
import com.github.apognu.otter.utils.RadiosResponse
|
||||
|
@ -12,22 +11,14 @@ import java.io.BufferedReader
|
|||
|
||||
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
|
||||
override val cacheId = "radios"
|
||||
override val upstream = HttpUpstream<Radio, FunkwhaleResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/", object : TypeToken<RadiosResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken<RadiosResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Radio>) = RadiosCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Radio>): List<Radio> {
|
||||
return data
|
||||
.map { radio ->
|
||||
radio.apply { radio_type = "custom" }
|
||||
}
|
||||
.map { radio -> radio.apply { radio_type = "custom" } }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
context?.let { context ->
|
||||
add(0, Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)))
|
||||
add(1, Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +1,29 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.CacheItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.io.BufferedReader
|
||||
import kotlin.math.ceil
|
||||
|
||||
interface Upstream<D> {
|
||||
fun fetch(size: Int = 0): Flow<Repository.Response<D>>
|
||||
}
|
||||
|
||||
abstract class Repository<D : Any, C : CacheItem<D>> {
|
||||
protected val scope: CoroutineScope = CoroutineScope(Job() + IO)
|
||||
|
||||
enum class Origin(val origin: Int) {
|
||||
Cache(0b01),
|
||||
Network(0b10)
|
||||
}
|
||||
|
||||
data class Response<D>(val origin: Origin, val data: List<D>, val hasMore: Boolean)
|
||||
data class Response<D>(val origin: Origin, val data: List<D>, val page: Int, val hasMore: Boolean)
|
||||
|
||||
abstract val context: Context?
|
||||
abstract val cacheId: String?
|
||||
|
@ -35,17 +41,19 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
|
|||
cacheId?.let { cacheId ->
|
||||
Cache.get(context, cacheId)?.let { reader ->
|
||||
uncache(reader)?.let { cache ->
|
||||
emit(Response(Origin.Cache, cache.data, false))
|
||||
return@flow emit(Response(Origin.Cache, cache.data, ceil(cache.data.size / AppContext.PAGE_SIZE.toDouble()).toInt(), false))
|
||||
}
|
||||
}
|
||||
|
||||
return@flow emit(Response(Origin.Cache, listOf(), 1, false))
|
||||
}
|
||||
}.flowOn(IO)
|
||||
|
||||
private fun fromNetwork(size: Int) = flow {
|
||||
upstream
|
||||
.fetch(size)
|
||||
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.hasMore) }
|
||||
.collect { response -> emit(Response(Origin.Network, response.data, response.hasMore)) }
|
||||
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.page, response.hasMore) }
|
||||
.collect { response -> emit(Response(Origin.Network, response.data, response.page, response.hasMore)) }
|
||||
}
|
||||
|
||||
protected open fun onDataFetched(data: List<D>) = data
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
@ -9,37 +10,50 @@ import kotlinx.coroutines.flow.toList
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class TracksSearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() {
|
||||
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<Track, TracksCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream: Upstream<Track>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
track.favorite = favorites.contains(track.id)
|
||||
track.downloaded = downloaded.contains(track.id)
|
||||
|
||||
track.bestUpload()?.let { upload ->
|
||||
val url = mustNormalizeUrl(upload.listen_url)
|
||||
|
||||
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
|
||||
}
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ArtistsSearchRepository(override val context: Context?, query: String) : Repository<Artist, ArtistsCache>() {
|
||||
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<Artist, ArtistsCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
|
||||
override val upstream: Upstream<Artist>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Artist>) = ArtistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
|
||||
}
|
||||
|
||||
class AlbumsSearchRepository(override val context: Context?, query: String) : Repository<Album, AlbumsCache>() {
|
||||
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream = HttpUpstream<Album, FunkwhaleResponse<Album>>(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
|
||||
override val upstream: Upstream<Album>
|
||||
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Album>) = AlbumsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.apognu.otter.Otter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.toList
|
||||
|
@ -14,20 +13,49 @@ import java.io.BufferedReader
|
|||
|
||||
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "tracks-album-$albumId"
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type)
|
||||
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
companion object {
|
||||
fun getDownloadedIds(): List<Int>? {
|
||||
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
|
||||
val ids: MutableList<Int> = mutableListOf()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
ids.add(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
val downloaded = getDownloadedIds() ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
track.favorite = favorites.contains(track.id)
|
||||
track.downloaded = downloaded.contains(track.id)
|
||||
|
||||
track.bestUpload()?.let { upload ->
|
||||
val url = mustNormalizeUrl(upload.listen_url)
|
||||
|
||||
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
|
||||
}
|
||||
|
||||
track
|
||||
}.sortedBy { it.position }
|
||||
}.sortedWith(compareBy({ it.disc_number }, { it.position }))
|
||||
}
|
||||
}
|
|
@ -16,7 +16,9 @@ object AppContext {
|
|||
const val PREFS_CREDENTIALS = "credentials"
|
||||
|
||||
const val NOTIFICATION_MEDIA_CONTROL = 1
|
||||
const val NOTIFICATION_DOWNLOADS = 2
|
||||
const val NOTIFICATION_CHANNEL_MEDIA_CONTROL = "mediacontrols"
|
||||
const val NOTIFICATION_CHANNEL_DOWNLOADS = "downloads"
|
||||
|
||||
const val PAGE_SIZE = 50
|
||||
const val TRANSITION_DURATION = 300L
|
||||
|
@ -62,6 +64,24 @@ object AppContext {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).let { manager ->
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_DOWNLOADS,
|
||||
"Downloads",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).run {
|
||||
description = "Downloads"
|
||||
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
|
||||
manager.createNotificationChannel(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package com.github.apognu.otter.utils
|
||||
|
||||
import com.github.apognu.otter.Otter
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.android.exoplayer2.offline.DownloadCursor
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
|
@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.conflate
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class Command {
|
||||
class StartService(val command: Command) : Command()
|
||||
object RefreshService : Command()
|
||||
|
||||
object ToggleState : Command()
|
||||
|
@ -19,16 +22,22 @@ sealed class Command {
|
|||
class Seek(val progress: Int) : Command()
|
||||
|
||||
class AddToQueue(val tracks: List<Track>) : Command()
|
||||
class AddToPlaylist(val tracks: List<Track>) : Command()
|
||||
class PlayNext(val track: Track) : Command()
|
||||
class ReplaceQueue(val queue: List<Track>, val fromRadio: Boolean = false) : Command()
|
||||
class RemoveFromQueue(val track: Track) : Command()
|
||||
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
|
||||
object ClearQueue : Command()
|
||||
object ShuffleQueue : Command()
|
||||
class PlayRadio(val radio: Radio) : Command()
|
||||
|
||||
class SetRepeatMode(val mode: Int) : Command()
|
||||
|
||||
class PlayTrack(val index: Int) : Command()
|
||||
class PinTrack(val track: Track) : Command()
|
||||
class PinTracks(val tracks: List<Track>) : Command()
|
||||
|
||||
class RefreshTrack(val track: Track?) : Command()
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
|
@ -37,30 +46,32 @@ sealed class Event {
|
|||
class PlaybackError(val message: String) : Event()
|
||||
object PlaybackStopped : Event()
|
||||
class Buffering(val value: Boolean) : Event()
|
||||
class TrackPlayed(val track: Track?, val play: Boolean) : Event()
|
||||
class TrackFinished(val track: Track?) : Event()
|
||||
class RefreshTrack(val track: Track?, val play: Boolean) : Event()
|
||||
class StateChanged(val playing: Boolean) : Event()
|
||||
object QueueChanged : Event()
|
||||
object RadioStarted : Event()
|
||||
object ListingsChanged : Event()
|
||||
class DownloadChanged(val download: Download) : Event()
|
||||
}
|
||||
|
||||
sealed class Request(var channel: Channel<Response>? = null) {
|
||||
object GetState : Request()
|
||||
object GetQueue : Request()
|
||||
object GetCurrentTrack : Request()
|
||||
object GetDownloads : Request()
|
||||
}
|
||||
|
||||
sealed class Response {
|
||||
class State(val playing: Boolean) : Response()
|
||||
class Queue(val queue: List<Track>) : Response()
|
||||
class CurrentTrack(val track: Track?) : Response()
|
||||
class Downloads(val cursor: DownloadCursor) : Response()
|
||||
}
|
||||
|
||||
object EventBus {
|
||||
fun send(event: Event) {
|
||||
GlobalScope.launch {
|
||||
Otter.get().eventBus.send(event)
|
||||
GlobalScope.launch(IO) {
|
||||
Otter.get().eventBus.offer(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,18 +80,18 @@ object EventBus {
|
|||
|
||||
object CommandBus {
|
||||
fun send(command: Command) {
|
||||
GlobalScope.launch {
|
||||
get().offer(command)
|
||||
GlobalScope.launch(IO) {
|
||||
Otter.get().commandBus.offer(command)
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = Otter.get().commandBus
|
||||
fun get() = Otter.get().commandBus.asFlow()
|
||||
}
|
||||
|
||||
object RequestBus {
|
||||
fun send(request: Request): Channel<Response> {
|
||||
return Channel<Response>().also {
|
||||
GlobalScope.launch(Main) {
|
||||
GlobalScope.launch(IO) {
|
||||
request.channel = it
|
||||
|
||||
Otter.get().requestBus.offer(request)
|
||||
|
@ -93,7 +104,7 @@ object RequestBus {
|
|||
|
||||
object ProgressBus {
|
||||
fun send(current: Int, duration: Int, percent: Int) {
|
||||
GlobalScope.launch {
|
||||
GlobalScope.launch(IO) {
|
||||
Otter.get().progressBus.send(Triple(current, duration, percent))
|
||||
}
|
||||
}
|
|
@ -6,19 +6,21 @@ import com.github.apognu.otter.R
|
|||
import com.github.apognu.otter.fragments.BrowseFragment
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.kittinunf.fuel.core.Request
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.gson.Gson
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.RequestCreator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, hasMore: Boolean) -> Unit) {
|
||||
GlobalScope.launch(context) {
|
||||
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) {
|
||||
scope.launch(context) {
|
||||
collect { data ->
|
||||
callback(data.data, data.origin == Repository.Origin.Cache, data.hasMore)
|
||||
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -62,8 +64,8 @@ fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
|
|||
}
|
||||
|
||||
fun Picasso.maybeLoad(url: String?): RequestCreator {
|
||||
if (url == null) return load(R.drawable.cover)
|
||||
else return load(url)
|
||||
return if (url == null) load(R.drawable.cover)
|
||||
else load(url)
|
||||
}
|
||||
|
||||
fun Request.authorize(): Request {
|
||||
|
@ -73,3 +75,5 @@ fun Request.authorize(): Request {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
package com.github.apognu.otter.utils
|
||||
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.preference.PowerPreference
|
||||
|
||||
data class User(
|
||||
val full_username: String
|
||||
)
|
||||
|
||||
sealed class CacheItem<D : Any>(val data: List<D>)
|
||||
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
|
||||
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
|
||||
|
@ -12,46 +17,47 @@ class RadiosCache(data: List<Radio>) : CacheItem<Radio>(data)
|
|||
class FavoritedCache(data: List<Int>) : CacheItem<Int>(data)
|
||||
class QueueCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
|
||||
abstract class FunkwhaleResponse<D : Any> {
|
||||
abstract class OtterResponse<D : Any> {
|
||||
abstract val count: Int
|
||||
abstract val next: String?
|
||||
|
||||
abstract fun getData(): List<D>
|
||||
}
|
||||
|
||||
data class UserResponse(override val count: Int, override val next: String?, val results: List<Artist>) : FunkwhaleResponse<Artist>() {
|
||||
data class UserResponse(override val count: Int, override val next: String?, val results: List<Artist>) : OtterResponse<Artist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : FunkwhaleResponse<Artist>() {
|
||||
data class ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : OtterResponse<Artist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : FunkwhaleResponse<Album>() {
|
||||
data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : OtterResponse<Album>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : FunkwhaleResponse<Track>() {
|
||||
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : OtterResponse<Track>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class FavoritedResponse(override val count: Int, override val next: String?, val results: List<Favorited>) : FunkwhaleResponse<Int>() {
|
||||
data class FavoritedResponse(override val count: Int, override val next: String?, val results: List<Favorited>) : OtterResponse<Int>() {
|
||||
override fun getData() = results.map { it.track }
|
||||
}
|
||||
|
||||
data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List<Playlist>) : FunkwhaleResponse<Playlist>() {
|
||||
data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List<Playlist>) : OtterResponse<Playlist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List<PlaylistTrack>) : FunkwhaleResponse<PlaylistTrack>() {
|
||||
data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List<PlaylistTrack>) : OtterResponse<PlaylistTrack>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : FunkwhaleResponse<Radio>() {
|
||||
data class RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : OtterResponse<Radio>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class Covers(val original: String)
|
||||
data class Covers(val urls: CoverUrls)
|
||||
data class CoverUrls(val original: String)
|
||||
|
||||
typealias AlbumList = List<Album>
|
||||
|
||||
|
@ -65,11 +71,12 @@ data class Album(
|
|||
val id: Int,
|
||||
val artist: Artist,
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
val cover: Covers?,
|
||||
val release_date: String?
|
||||
) : SearchResult {
|
||||
data class Artist(val name: String)
|
||||
|
||||
override fun cover() = cover.original
|
||||
override fun cover() = cover?.urls?.original
|
||||
override fun title() = title
|
||||
override fun subtitle() = artist.name
|
||||
}
|
||||
|
@ -81,24 +88,39 @@ data class Artist(
|
|||
) : SearchResult {
|
||||
data class Album(
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
val cover: Covers?
|
||||
)
|
||||
|
||||
override fun cover() = albums?.getOrNull(0)?.cover?.original
|
||||
override fun cover(): String? = albums?.getOrNull(0)?.cover?.urls?.original
|
||||
override fun title() = name
|
||||
override fun subtitle() = "Artist"
|
||||
}
|
||||
|
||||
data class Track(
|
||||
val id: Int,
|
||||
val id: Int = 0,
|
||||
val title: String,
|
||||
val artist: Artist,
|
||||
val album: Album,
|
||||
val position: Int,
|
||||
val uploads: List<Upload>
|
||||
val album: Album?,
|
||||
val disc_number: Int = 0,
|
||||
val position: Int = 0,
|
||||
val uploads: List<Upload> = listOf(),
|
||||
val copyright: String? = null,
|
||||
val license: String? = null
|
||||
) : SearchResult {
|
||||
var current: Boolean = false
|
||||
var favorite: Boolean = false
|
||||
var cached: Boolean = false
|
||||
var downloaded: Boolean = false
|
||||
|
||||
companion object {
|
||||
fun fromDownload(download: DownloadInfo): Track = Track(
|
||||
id = download.id,
|
||||
title = download.title,
|
||||
artist = Artist(0, download.artist, listOf()),
|
||||
album = Album(0, Album.Artist(""), "", Covers(CoverUrls("")), ""),
|
||||
uploads = listOf(Upload(download.contentId, 0, 0))
|
||||
)
|
||||
}
|
||||
|
||||
data class Upload(
|
||||
val listen_url: String,
|
||||
|
@ -123,7 +145,7 @@ data class Track(
|
|||
}
|
||||
}
|
||||
|
||||
override fun cover() = album.cover.original
|
||||
override fun cover() = album?.cover?.urls?.original
|
||||
override fun title() = title
|
||||
override fun subtitle() = artist.name
|
||||
}
|
||||
|
@ -144,5 +166,14 @@ data class Radio(
|
|||
val id: Int,
|
||||
var radio_type: String,
|
||||
val name: String,
|
||||
val description: String
|
||||
val description: String,
|
||||
var related_object_id: String? = null
|
||||
)
|
||||
|
||||
data class DownloadInfo(
|
||||
val id: Int,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
var download: Download?
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ import android.widget.Toast
|
|||
import com.google.android.exoplayer2.util.Log
|
||||
import com.preference.PowerPreference
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
|
||||
fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
|
||||
if (this != null) {
|
||||
|
@ -13,27 +12,46 @@ fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Any.log(message: String) {
|
||||
Log.d("FUNKWHALE", "${this.javaClass.simpleName}: $message")
|
||||
private fun logClassName(): String {
|
||||
val known = setOf(
|
||||
"dalvik.system.VMStack",
|
||||
"java.lang.Thread",
|
||||
"com.github.apognu.otter.utils.UtilKt"
|
||||
)
|
||||
|
||||
Thread.currentThread().stackTrace.forEach {
|
||||
if (!known.contains(it.className)) {
|
||||
val className = it.className.split('.').last()
|
||||
val line = it.lineNumber
|
||||
|
||||
return "$className:$line"
|
||||
}
|
||||
}
|
||||
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
fun Any.log() {
|
||||
Log.d("FUNKWHALE", this.toString())
|
||||
fun Any?.log(prefix: String? = null) {
|
||||
prefix?.let {
|
||||
Log.d("OTTER", "${logClassName()} - $prefix: $this")
|
||||
} ?: Log.d("OTTER", "${logClassName()} - $this")
|
||||
}
|
||||
|
||||
fun maybeNormalizeUrl(rawUrl: String?): String? {
|
||||
if (rawUrl == null || rawUrl.isEmpty()) return null
|
||||
try {
|
||||
if (rawUrl == null || rawUrl.isEmpty()) return null
|
||||
|
||||
return mustNormalizeUrl(rawUrl)
|
||||
return mustNormalizeUrl(rawUrl)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun mustNormalizeUrl(rawUrl: String): String {
|
||||
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
|
||||
val uri = URI(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
|
||||
|
||||
return uri.toURL().run {
|
||||
URL("https", host, file)
|
||||
}.toString()
|
||||
return uri.toString()
|
||||
}
|
||||
|
||||
fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
|
||||
|
@ -57,4 +75,6 @@ object Settings {
|
|||
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
|
||||
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
|
||||
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
|
||||
|
||||
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,30 +6,6 @@ import android.graphics.drawable.Drawable
|
|||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.github.apognu.otter.R
|
||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
|
||||
object LoadingFlotingActionButton {
|
||||
fun start(button: ExtendedFloatingActionButton): ObjectAnimator {
|
||||
button.isEnabled = false
|
||||
button.setIconResource(R.drawable.fab_spinner)
|
||||
button.shrink()
|
||||
|
||||
return ObjectAnimator.ofFloat(button, View.ROTATION, 0f, 360f).apply {
|
||||
duration = 500
|
||||
repeatCount = ObjectAnimator.INFINITE
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(button: ExtendedFloatingActionButton, animator: ObjectAnimator) {
|
||||
animator.cancel()
|
||||
|
||||
button.isEnabled = true
|
||||
button.setIconResource(R.drawable.play)
|
||||
button.rotation = 0.0f
|
||||
button.extend()
|
||||
}
|
||||
}
|
||||
|
||||
object LoadingImageView {
|
||||
fun start(context: Context?, image: ImageView): ObjectAnimator? {
|
||||
|
|
|
@ -72,7 +72,7 @@ class NowPlayingView : MaterialCardView {
|
|||
}
|
||||
|
||||
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
|
||||
var maxHeight = 0
|
||||
private var maxHeight = 0
|
||||
private var minHeight = 0
|
||||
private var maxMargin = 0
|
||||
|
||||
|
@ -100,8 +100,6 @@ class NowPlayingView : MaterialCardView {
|
|||
initialTouchY = e.rawY
|
||||
lastTouchY = e.rawY
|
||||
|
||||
flingAnimator?.cancel()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
apognu@gmail.com
|
||||
otter@support.popineau.eu
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
https://github.com/apognu/otter
|
|
@ -2,5 +2,8 @@ Otter is a simple music player that allows you to stream the audio content of yo
|
|||
|
||||
This app requires an account on a Funkwhale instance to work.
|
||||
|
||||
You can get support or take a part in Otter's development by visiting our GitHub project or join us on Matrix.
|
||||
|
||||
Source code : https://github.com/apognu/otter
|
||||
Matrix room: https://matrix.to/#/#otter:matrix.org
|
||||
Funkwhale : https://funkwhale.audio
|
||||
|
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 302 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 441 KiB |
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 967 KiB |
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 822 KiB 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 |
|
@ -1,6 +1,8 @@
|
|||
Otter est un lecteur de musique basique qui vous permet de profiter du contenu audio de votre instance Funkwhale.
|
||||
|
||||
Cette application nécessite un compte sur un instance Funkwhale pour fonctionner.
|
||||
Cette application nécessite un compte sur une instance Funkwhale pour fonctionner.
|
||||
|
||||
Vous pouvez obtenir de l'aide ou participer au développement d'Otter en vous rendant sur notre projet GitHub ou nous rejoindre sur Matrix.
|
||||
|
||||
Code source : https://github.com/apognu/otter
|
||||
Funkwhale : https://funkwhale.audio
|
||||
|
|
|
@ -1 +1 @@
|
|||
../../../../../../fastlane/metadata/android/en-US/changelogs/1000018.txt
|
||||
../../../../../../fastlane/metadata/android/en-US/changelogs/1000021.txt
|
|
@ -1 +1 @@
|
|||
../../../../../../fastlane/metadata/android/fr-FR/changelogs/1000018.txt
|
||||
../../../../../../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 |
Before Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.2 KiB |
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14,10L2,10v2h12v-2zM14,6L2,6v2h12L14,6zM18,14v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2,16h8v-2L2,14v2z"/>
|
||||
</vector>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
|
||||
<solid android:color="@color/elevatedSurface" />
|
||||
<padding android:top="5dp" android:left="5dp" android:right="5dp" android:bottom="5dp" />
|
||||
|
||||
</shape>
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/current" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||
</vector>
|
|
@ -0,0 +1,8 @@
|
|||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:drawable="@drawable/downloads" />
|
||||
|
||||
</layer-list>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
|
||||
</vector>
|