Compare commits

..

2 commits
dev ... tasker

Author SHA1 Message Date
Alexander Capehart
8094ff05d5
playback: stop gap that occurs between load/playback 2024-07-27 19:48:48 -06:00
Alexander Capehart
8bc7418887
tasker: kind of working plugin 2024-05-18 17:24:08 -06:00
589 changed files with 16887 additions and 26010 deletions

View file

@ -34,7 +34,6 @@ body:
attributes:
label: What android version do you use?
options:
- Android 15
- Android 14
- Android 13
- Android 12L

View file

@ -2,37 +2,33 @@ name: Android CI
on:
push:
branches: []
branches: [ "dev" ]
pull_request:
branches: []
branches: [ "dev" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Install ninja-build
run: sudo apt-get install -y ninja-build
- name: Clone repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Clone submodules
run: git submodule update --init --recursive --remote
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Check formatting with spotless
run: ./gradlew spotlessCheck
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Test app with Gradle
run: ./gradlew app:testDebug
- name: Build debug APK with Gradle
run: ./gradlew app:packageDebug
- name: Upload debug APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.1
with:
name: Auxio_Canary
path: ./app/build/outputs/apk/debug/app-debug.apk

3
.gitignore vendored
View file

@ -13,6 +13,3 @@ captures/
.externalNativeBuild
*.iml
.cxx
.kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,8 +1,3 @@
[submodule "media"]
path = media
url = https://github.com/OxygenCobalt/media.git
[submodule "musikr/src/main/cpp/taglib"]
path = musikr/src/main/cpp/taglib
url = https://github.com/taglib/taglib.git
tag = ee1931b

View file

@ -1,205 +1,23 @@
# Changelog
## 4.0.3
#### What's Improved
- Improved music loader pipeline efficiency
- Made cover.png support more flexible
- Albums with the same name but different album artists are now split
if fully tagged with album artists
#### What's Fixed
- Possibly fixed cache failures on large libraries
- Possibly fixed playback state saving failing on some devices
- Fixed issue where artists w/o songs would not have a cover
- Fixed music not being reloaded when music locations changed
- Fixed tasker media control not working
- Fixed tasker playback start command never finishing
#### Dev/Meta
- Removed useless storage permissions
- Internal cleanup/simplification of musikr API
- Removed unused resources
#### What's Fixed
## 4.0.2
#### What's New
- Added back in support for cover art from cover.png/cover.jpg
- Added "As is" cover art setting
- Option to include hidden files or not (off by default)
#### What's Improved
- Reduced elevation contrast in black theme
#### What's Fixed
- Fixed incorrect extension stripping on some files
- Fixed various errors in new branding
- Fixed MTE segfault from improper string handling
#### What's Changed
- Hidden files no longer loaded by default
## 4.0.1
#### What's Fixed
- Fixed music loading hanging on files without tags
- Fixed playlists being destroyed in poorly tagged libraries
## 4.0.0
#### What's New
- A total user interface refresh based on the latest Material Design specs
- New theme palettes
- Improved designs for playback and detail views
- New app branding and icon
- Refreshed round mode
- Less intrusive music loading indicators
- **Musikr**, a brand new music loading system
- Directly accesses user files rather than unreliable media database
- Uses faster and more capable native tag parsing
- Stores cover data on-device for fast and high-quality access
- New interpretation system with many quality-of-life improvements
- Android 15 support
#### What's Improved
- Initial music loading is signifigantly faster and less resource intensive
- Album grouping no longer done with artist
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
- M3U playlist file name is now proposed if one cannot be found within the file
- Duration is now parsed from certain files that previously could not be parsed
- ID3v2 tags are now parsed from WAV files
- NN/TT tracks/discs are now handled in Vorbis
- Music library will is less likely to fail to respond to updates
- Hidden audio files can now be loaded
- Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors
- Added fast scrolling in detail views
- Added ability to make issues and make feedback e-mails in-app
#### What's Fixed
- Fixed playback sheet flickering on warm start
- No longer possible to save a sort with no direction specified
- Fixed inconsistent corner radii in widget
- Possibly fixed foreground start music loading failures
- Fixed playlist view not exiting on deletion
#### What's Changed
- Date added is now local to when the app discovers the file and will not
persist long-term
- Songs with no album are now "Unknown album" rather than folder name
- Tab layout no longer changes depending on device configuration
- Round mode is now on by default
#### Dev/Meta
- No longer using custom logging setup
- Music loading split off into separate musikr module
## 3.6.3
#### What's Fixed
- Fixed broken replaygain
- Fixed hide collaborators being broken
- Fixed crash when navigating to artists w/appearances
- Fixed headers appearing on empty detail sections
## 3.6.2
#### What's Fixed
- Fixed broken notification close action
#### Dev/Meta
- Fixed mismatched NDK versions
## 3.6.1
#### What's Fixed
- Fixed possible crash from poor service initalization
- Fixed issue where it was impossible to edit playlists
- Fixed issue where playlist would revert to older version when re-edited
#### Dev/Meta
- Fixed service memory leaks
## 3.6.0
#### What's New
- Added support for playback from google assistant
#### What's Improved
- Home and detail UIs in Android Auto now reflect app sort settings
- Album view now shows discs in android auto
#### What's Fixed
- Fixed playback briefly pausing when adding songs to playlist
- Fixed media lists in Android Auto being truncated in some cases
- Possibly fixed duplicated song items depending on album/all children
- Possibly fixed truncated tab lists in android auto
#### Dev/Meta
- Moved to raw media session apis rather than media3 session
## 3.5.3
#### What's New
- Basic Tasker integration for safely starting Auxio's service
#### What's Improved
- Added support for informal singular-spaced tags like `album artist` in
file metadata
#### What's Fixed
- Fix "Foreground not allowed" music loading crash from starting too early
- Fixed widget not loading on some devices due to the cover being too large
## 3.5.2
#### What's Fixed
- Fixed music loading failure from improper sort systems (For real this time)
## 3.5.1
#### What's Fixed
- Fixed music loading failure from improper sort systems
## 3.5.0
#### What's New
- Android Auto support
- Full media browser implementation
- Service can now operate independently of app
- Added basic tasker plugin
#### What's Improved
- Album covers are now loaded on a per-song basis
- MP4 sort tags are now correctly interpreted
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
- M3U paths are now interpreted both as relative and absolute regardless of the format
- Added support for M3U paths starting with /storage/
- Queue no longer scrolls as quickly when dragging items
#### What's Fixed
- Fixed repeat mode not restoring on startup
- Fixed rewinding not occuring when skipping back at the beginning of the queue if
rewind before skipping was turned off
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
#### What's Changed
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
- Playback will close automatically after some time left idle
#### Dev/Meta
- Use WEBP instead of PNG icons
#### dev -> release changes
- Re-added ability to open app from clicking on notification
- Removed tasker plugin
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
- M3U paths are now interpreted both as relative and absolute regardless of the format
- Added support for M3U paths starting with /storage/
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
- Made album cover keying more efficient at the cost of resillience
- Fixed android auto queue not respecting shuffle
## 3.4.3
#### What's Improved

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.4.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.4.3&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,12 +15,7 @@
</p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
<a href="https://accrescent.app/app/org.oxycblt.auxio">
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
</a>
</p>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
</p>
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
</p>
@ -54,7 +51,6 @@ precise/original dates, sort tags, and more
- SD Card-aware folder management
- Reliable playlisting functionality
- Playback state persistence
- Android auto support
- Automatic gapless playback
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
@ -64,13 +60,13 @@ precise/original dates, sort tags, and more
- Headset autoplay
- Stylish widgets that automatically adapt to their size
- Completely private and offline
- No rounded album covers (if you want them)
- No rounded album covers (by default)
## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
## Donate
@ -79,9 +75,7 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
<p align="center"><b>$16/month supporters:</b></p>
<p align="center">
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
<br/>
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=100 /><p align="center"><b><a href="https://github.com/yrliet">yrliet</a></b></p></a>
</p>
<p align="center"><b>$8/month supporters:</b></p>
@ -89,14 +83,12 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
<p align="center">
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
<a href="https://github.com/gtsiam"><img src="https://avatars.githubusercontent.com/u/7459196?v=4" width=50 /></a>
</p>
## Building
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
parsing. This adds some caveats to the build process:
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
1. `cmake` and `ninja-build` must be installed before building the project.
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code.

View file

View file

@ -2,6 +2,7 @@ plugins {
id "com.android.application"
id "kotlin-android"
id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "kotlin-kapt"
@ -10,19 +11,21 @@ plugins {
}
android {
compileSdk 35
// Auxio implicitly depends on the native modules, explicitly specify it
// here so the libraries are still stripped.
ndkVersion ndk_version
compileSdk 34
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified
ndkVersion = "25.2.9519653"
namespace "org.oxycblt.auxio"
defaultConfig {
applicationId namespace
versionName "4.0.4"
versionCode 63
versionName "3.5.0-dev"
versionCode 45
minSdk min_sdk
targetSdk target_sdk
minSdk 24
targetSdk 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@ -67,7 +70,6 @@ android {
buildFeatures {
viewBinding true
buildConfig true
}
}
@ -77,16 +79,16 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
def coroutines_version = '1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT ---
// General
implementation "androidx.core:core-ktx:$core_version"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
// noinspection GradleDependency
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.activity:activity-ktx:1.8.2"
implementation "androidx.fragment:fragment-ktx:1.6.2"
// Components
@ -95,13 +97,11 @@ dependencies {
// TODO: Report this issue and hope for a timely fix
// noinspection GradleDependency
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.2.0"
// 1.1.0 upgrades recyclerview to 1.3.0, keep it on 1.0.0
//noinspection GradleDependency
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.0.0"
// Lifecycle
def lifecycle_version = "2.8.7"
def lifecycle_version = "2.7.0"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -114,38 +114,30 @@ dependencies {
// Media
implementation "androidx.media:media:1.7.0"
// Android Auto
implementation "androidx.car.app:app:1.4.0"
// Preferences
implementation "androidx.preference:preference-ktx:1.2.1"
// Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// Build
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
// --- SECOND PARTY ---
// Musikr
implementation project(":musikr")
// --- THIRD PARTY ---
// Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
// Image loading
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
implementation 'io.coil-kt:coil-base:2.4.0'
// Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
implementation "com.google.android.material:material:1.13.0-alpha07"
implementation "com.google.android.material:material:1.10.0"
// Dependency Injection
implementation "com.google.dagger:dagger:$hilt_version"
@ -159,9 +151,27 @@ dependencies {
// Speed dial
implementation "com.leinardi.android:speed-dial:3.3.0"
// Tasker integration
// Tasker
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
// Fuzzy search
implementation 'org.apache.commons:commons-text:1.9'
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
testImplementation "org.robolectric:robolectric:4.11"
testImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
spotless {
kotlin {
target "src/**/*.kt"
ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE")
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
}

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="info_app_name" translatable="false">Auxio Debug</string>
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
</resources>

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -45,7 +48,6 @@
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:allowCrossUidActivitySwitchFromBelow="false"
android:roundIcon="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustPan">
@ -90,22 +92,13 @@
android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:roundIcon="@mipmap/ic_launcher"
tools:ignore="ExportedService">
android:roundIcon="@mipmap/ic_launcher">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!--
Expose Auxio's cover data to the android system
-->
<provider
android:name=".image.CoverProvider"
android:authorities="@string/pkg_authority_cover"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info.
@ -142,12 +135,12 @@
android:resource="@xml/widget_info" />
</receiver>
<!-- Tasker 'start service' integration -->
<activity
android:name=".tasker.ActivityConfigStartAction"
android:name=".tasker.StartConfigBasicAction"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/lbl_start_playback">
android:label="Start">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
</intent-filter>

View file

@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally.");
}
if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return;
}
final int finalState;
@ -1389,10 +1390,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return shouldRemoveExpandedCorners;
}
public void killCorners() {
materialShapeDrawable.setCornerSize(0f);
}
/**
* Gets the current state of the bottom sheet.
*
@ -1632,13 +1629,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return;
}
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
return;
}
if (canActuallyHide) {
if (hideable) {
bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent,
new AnimatorListenerAdapter() {

View file

@ -29,7 +29,6 @@ import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.CopyleftNoticeTree
import timber.log.Timber
/**
@ -46,11 +45,7 @@ class Auxio : Application() {
override fun onCreate() {
super.onCreate()
@Suppress("KotlinConstantConditions")
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
Timber.plant(CopyleftNoticeTree())
} else if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}

View file

@ -19,154 +19,97 @@
package org.oxycblt.auxio
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber
import org.oxycblt.auxio.music.service.IndexerServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
import org.oxycblt.auxio.util.logD
@AndroidEntryPoint
class AuxioService :
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
private lateinit var playbackFragment: PlaybackServiceFragment
class AuxioService : MediaLibraryService(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
private lateinit var musicFragment: MusicServiceFragment
@Inject lateinit var indexingFragment: IndexerServiceFragment
private var nativeStart = false
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
playbackFragment = playbackFragmentFactory.create(this, this)
musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach()
musicFragment.attach()
Timber.d("Service Created")
mediaSessionFragment.attach(this, this)
indexingFragment.attach(this)
}
override fun onBind(intent: Intent?): IBinder? {
// handleIntent(intent)
return super.onBind(intent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here.
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent)
// If we die we want to not restart, we will immediately try to foreground in and just
// fail to start again since the activity will be dead too. This is not the semantically
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
// weird foreground errors.
return START_NOT_STICKY
handleIntent(intent)
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent)
return binder
}
private fun onHandleForeground(intent: Intent?) {
musicFragment.start()
playbackFragment.start(intent)
private fun handleIntent(intent: Intent?) {
nativeStart = intent?.getBooleanExtra(INTENT_KEY_INTERNAL_START, false) ?: false
logD("${intent} $nativeStart")
if (!nativeStart) {
// Some foreign code started us, no guarantees about foreground stability. Figure
// out what to do.
mediaSessionFragment.handleNonNativeStart()
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
playbackFragment.handleTaskRemoved()
mediaSessionFragment.handleTaskRemoved()
}
override fun onDestroy() {
super.onDestroy()
musicFragment.release()
playbackFragment.release()
indexingFragment.release()
mediaSessionFragment.release()
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
return musicFragment.getRoot()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaSessionFragment.mediaSession
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
musicFragment.getItem(itemId, result)
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, null)
}
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle
) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage())
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {
musicFragment.search(query, result, extras?.getPage())
}
private fun getRootChildrenLimit(): Int {
return browserRootHints?.getInt(
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4
}
private fun Bundle.getPage(): MusicServiceFragment.Page? {
val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null
val pageSize =
getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null
return MusicServiceFragment.Page(page, pageSize)
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
override fun updateForeground(change: ForegroundListener.Change) {
val mediaNotification = playbackFragment.notification
if (mediaNotification != null) {
val state = mediaSessionFragment.hasNotification()
if (state == MediaSessionServiceFragment.NotificationState.RUNNING) {
if (change == ForegroundListener.Change.MEDIA_SESSION) {
startForeground(mediaNotification.code, mediaNotification.build())
mediaSessionFragment.createNotification {
startForeground(it.notificationId, it.notification)
}
}
// Nothing changed, but don't show anything music related since we can always
// index during playback.
isForeground = true
} else {
musicFragment.createNotification {
indexingFragment.createNotification {
if (it != null) {
startForeground(it.code, it.build())
isForeground = true
} else {
} else if (state == MediaSessionServiceFragment.NotificationState.NOT_RUNNING) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
}
}
}
}
override fun invalidateMusic(mediaId: String) {
notifyChildrenChanged(mediaId)
}
companion object {
const val ACTION_START = BuildConfig.APPLICATION_ID + ".service.START"
var isForeground = false
private set
// This is only meant for Auxio to internally ensure that it's state management will work.
const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
const val INTENT_KEY_INTERNAL_START = BuildConfig.APPLICATION_ID + ".service.INTERNAL_START"
}
}
@ -178,42 +121,3 @@ interface ForegroundListener {
INDEXER
}
}
/**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}

View file

@ -49,24 +49,17 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
/** EditHeaderViewHolder */
const val VIEW_TYPE_EDIT_HEADER = 0xA00D
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
/** PlaylistSongViewHolder */
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
const val TASKER_ERROR_NOT_RESTORED = 0xA0A2
/** MainActivity Intent request code */
const val REQUEST_CODE = 0xA0C0
/** Activity AuxioService Start ID */
const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */
@ -125,10 +118,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C
/** CoverMode.Balanced */
const val COVER_MODE_BALANCED = 0xA11D
/** CoverMode.MediaStore */
const val COVER_MODE_MEDIA_STORE = 0xA11D
/** CoverMode.Quality */
const val COVER_MODE_HIGH_QUALITY = 0xA11E
const val COVER_MODE_QUALITY = 0xA11E
/** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */
@ -141,8 +134,7 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124
/** CoverMode.SaveSpace */
const val COVER_MODE_SAVE_SPACE = 0xA125
/** CoverMode.AsIs */
const val COVER_MODE_AS_IS = 0xA126
const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125
const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126
const val PLAYER_COMMAND_EXIT = 0xA127
}

View file

@ -33,8 +33,9 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.systemBarInsetsCompat
import timber.log.Timber as L
/**
* Auxio's single [AppCompatActivity].
@ -62,7 +63,7 @@ class MainActivity : AppCompatActivity() {
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupEdgeToEdge(binding.root)
L.d("Activity created")
logD("Activity created")
}
override fun onResume() {
@ -70,16 +71,15 @@ class MainActivity : AppCompatActivity() {
startService(
Intent(this, AuxioService::class.java)
.setAction(AuxioService.ACTION_START)
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
.putExtra(AuxioService.INTENT_KEY_INTERNAL_START, true))
if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
playbackModel.playDeferred(DeferredPlayback.RestoreState(sessionRequired = false))
}
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
startIntentAction(intent)
}
@ -90,10 +90,10 @@ class MainActivity : AppCompatActivity() {
// Apply the color scheme. The black theme requires it's own set of themes since
// it's not possible to modify the themes at run-time.
if (isNight && uiSettings.useBlackTheme) {
L.d("Applying black theme [accent ${uiSettings.accent}]")
logD("Applying black theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.blackTheme)
} else {
L.d("Applying normal theme [accent ${uiSettings.accent}]")
logD("Applying normal theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.theme)
}
}
@ -120,7 +120,7 @@ class MainActivity : AppCompatActivity() {
private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) {
// Nothing to do.
L.d("No intent to handle")
logD("No intent to handle")
return false
}
@ -129,7 +129,7 @@ class MainActivity : AppCompatActivity() {
// This is because onStart can run multiple times, and thus we really don't
// want to return false and override the original delayed action with a
// RestoreState action.
L.d("Already used this intent")
logD("Already used this intent")
return true
}
intent.putExtra(KEY_INTENT_USED, true)
@ -139,11 +139,11 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
else -> {
L.w("Unexpected intent ${intent.action}")
logW("Unexpected intent ${intent.action}")
return false
}
}
L.d("Translated intent to $action")
logD("Translated intent to $action")
playbackModel.playDeferred(action)
return true
}

View file

@ -22,25 +22,21 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewTreeObserver
import android.view.WindowInsets
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.transition.MaterialFadeThrough
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import com.leinardi.android.speeddial.SpeedDialOverlayLayout
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Method
import javax.inject.Inject
import java.lang.reflect.Field
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
@ -49,15 +45,13 @@ import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
@ -65,12 +59,11 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
@ -79,10 +72,7 @@ import timber.log.Timber as L
*/
@AndroidEntryPoint
class MainFragment :
ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener,
SpeedDialView.OnActionSelectedListener {
private val musicModel: MusicViewModel by activityViewModels()
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels()
@ -91,13 +81,9 @@ class MainFragment :
private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var navigationListener: DialogAwareNavigationListener? = null
private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
private var normalCornerSize = 0f
private var maxScaleXDistance = 0f
private var sheetRising: Boolean? = null
@Inject lateinit var uiSettings: UISettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -112,13 +98,10 @@ class MainFragment :
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
playbackSheetBehavior.uiSettings = uiSettings
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
queueSheetBehavior?.uiSettings = uiSettings
elevationNormal = binding.context.getDimen(MR.dimen.m3_sys_elevation_level1)
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
// that instantiating these callbacks in their respective fragments would result in the
@ -131,9 +114,10 @@ class MainFragment :
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
speedDialBackCallback = SpeedDialBackPressedCallback()
val speedDialBackCallback =
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
// --- UI SETUP ---
val context = requireActivity()
@ -151,50 +135,30 @@ class MainFragment :
if (queueSheetBehavior != null) {
// In portrait mode, set up click listeners on the stacked sheets.
L.d("Configuring stacked bottom sheets")
logD("Configuring stacked bottom sheets")
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
playbackModel.openQueue()
}
} else {
// Dual-pane mode, manually style the static queue sheet.
L.d("Configuring dual-pane bottom sheet")
logD("Configuring dual-pane bottom sheet")
binding.queueSheet.apply {
// Emulate the elevated bottom sheet style.
background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
shapeAppearanceModel =
ShapeAppearanceModel.builder(
context,
MR.style.ShapeAppearance_Material3_Corner_ExtraLarge,
MR.style.ShapeAppearanceOverlay_Material3_Corner_Top)
.build()
fillColor = context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal)
}
// Apply bar insets for the queue's RecyclerView to use.
setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets
}
}
}
normalCornerSize = playbackSheetBehavior.sheetBackgroundDrawable.topLeftCornerResolvedSize
maxScaleXDistance =
context.getDimen(MR.dimen.m3_back_progress_bottom_container_max_scale_x_distance)
binding.playbackSheet.elevation = 0f
binding.mainScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.sheetScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@MainFragment)
setChangeListener(::updateSpeedDial)
}
forceHideAllFabs()
updateSpeedDial(false)
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
binding.mainScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
binding.sheetScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
// --- VIEWMODEL SETUP ---
// This has to be done here instead of the playback panel to make sure that it's prioritized
@ -204,9 +168,7 @@ class MainFragment :
collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
collectImmediately(homeModel.speedDialOpen, ::handleSpeedDialState)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
@ -217,7 +179,7 @@ class MainFragment :
val binding = requireBinding()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
requireNotNull(navigationListener) { "NavigationListener was not available" }
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
.attach(binding.exploreNavHost.findNavController())
// Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively.
@ -240,7 +202,7 @@ class MainFragment :
override fun onStop() {
super.onStop()
val binding = requireBinding()
requireNotNull(navigationListener) { "NavigationListener was not available" }
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
.release(binding.exploreNavHost.findNavController())
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
}
@ -251,15 +213,13 @@ class MainFragment :
sheetBackCallback = null
detailBackCallback = null
selectionBackCallback = null
navigationListener = null
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
selectionNavigationListener = null
}
override fun onPreDraw(): Boolean {
// This is where I shove literally all the UI logic that won't behave any callback
// or "normal" method I've tried. Surely running this on every frame will actually cause
// it to work properly!
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
// sheets continually getting stuck. I need something with even more frequent updates,
// or otherwise bottom sheets get stuck.
// We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should
@ -271,55 +231,28 @@ class MainFragment :
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
// Stupid hack to prevent you from sliding the sheet up without closing the speed
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the
// speed dial, which is super finicky behavior.
val rising = playbackRatio > 0f
if (rising != sheetRising) {
sheetRising = rising
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
// Stupid hack to prevent you from sliding the sheet up without closing the speed
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
// dial, which is super finicky behavior.
homeModel.setSpeedDialOpen(false)
}
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f)
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2
val playbackMaxXScaleDelta = maxScaleXDistance / binding.playbackSheet.width
val playbackEdgeRatio = max(playbackRatio - 0.9f, 0f) / 0.1f
val playbackBackRatio =
max(1 - ((1 - binding.playbackSheet.scaleX) / playbackMaxXScaleDelta), 0f)
val playbackLastStretchRatio = min(playbackEdgeRatio * playbackBackRatio, 1f)
binding.mainSheetScrim.alpha = playbackLastStretchRatio
playbackSheetBehavior.sheetBackgroundDrawable.setCornerSize(
normalCornerSize * (1 - playbackLastStretchRatio))
binding.exploreNavHost.isInvisible = playbackLastStretchRatio == 1f
binding.playbackSheet.translationZ = (1 - playbackLastStretchRatio) * elevationNormal
val outPlaybackRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
if (queueSheetBehavior != null) {
// Queue sheet available, the normal transition applies, but it now much be combined
// with another transition where the playback panel disappears and the playback bar
// appears as the queue sheet expands.
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
val queueInRatio = max(queueRatio - 0.5f, 0f) * 2
val halfOutQueueRatio = min(queueRatio * 2, 1f)
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
val queueMaxXScaleDelta = maxScaleXDistance / binding.queueSheet.width
val queueBackRatio =
max(1 - ((1 - binding.queueSheet.scaleX) / queueMaxXScaleDelta), 0f)
val queueEdgeRatio = max(queueRatio - 0.9f, 0f) / 0.1f
val queueBarEdgeRatio = max(queueEdgeRatio - 0.5f, 0f) * 2
val queueBarBackRatio = max(queueBackRatio - 0.5f, 0f) * 2
val queueBarRatio = min(queueBarEdgeRatio * queueBarBackRatio, 1f)
val queuePanelEdgeRatio = min(queueEdgeRatio * 2, 1f)
val queuePanelBackRatio = min(queueBackRatio * 2, 1f)
val queuePanelRatio = 1 - min(queuePanelEdgeRatio * queuePanelBackRatio, 1f)
binding.playbackBarFragment.alpha = max(playbackOutRatio, queueBarRatio)
binding.playbackPanelFragment.alpha = min(playbackInRatio, queuePanelRatio)
binding.queueFragment.alpha = queueInRatio
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
binding.queueFragment.alpha = queueRatio
if (playbackModel.song.value != null) {
// Playback sheet intercepts queue sheet touch events, prevent that from
@ -329,18 +262,33 @@ class MainFragment :
}
} else {
// No queue sheet, fade normally based on the playback sheet
binding.playbackBarFragment.alpha = playbackOutRatio
binding.playbackPanelFragment.alpha = playbackInRatio
(binding.queueSheet.background as MaterialShapeDrawable).shapeAppearanceModel =
ShapeAppearanceModel.builder()
.setTopLeftCornerSize(normalCornerSize)
.setTopRightCornerSize(normalCornerSize * (1 - playbackLastStretchRatio))
.build()
binding.playbackBarFragment.alpha = 1 - halfOutRatio
binding.playbackPanelFragment.alpha = halfInPlaybackRatio
}
// Fade out the content as the playback panel expands.
// TODO: Replace with shadow?
binding.exploreNavHost.apply {
alpha = outPlaybackRatio
// Prevent interactions when the content fully fades out.
isInvisible = alpha == 0f
}
// Reduce playback sheet elevation as it expands. This involves both updating the
// shadow elevation for older versions, and fading out the background drawable
// containing the elevation overlay.
binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
// Fade out the playback bar as the panel expands.
binding.playbackBarFragment.apply {
// Prevent interactions when the playback bar fully fades out.
isInvisible = alpha == 0f
// As the playback bar expands, we also want to subtly translate the bar to
// align with the top inset. This results in both a smooth transition from the bar
// to the playback panel's toolbar, but also a correctly positioned playback bar
// for when the queue sheet expands.
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
}
// Prevent interactions when the playback panel fully fades out.
@ -348,7 +296,7 @@ class MainFragment :
binding.queueSheet.apply {
// Queue sheet (not queue content) should fade out with the playback panel.
alpha = playbackInRatio
alpha = halfInPlaybackRatio
// Prevent interactions when the queue sheet fully fades out.
binding.queueSheet.isInvisible = alpha == 0f
}
@ -367,160 +315,9 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true
}
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
L.d("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
L.d("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close the speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun onExploreNavigate() {
listModel.dropSelection()
updateFabVisibility(
requireBinding(),
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
private fun updateCurrentTab(tabType: MusicType) {
val binding = requireBinding()
updateFabVisibility(
binding, homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun updateIndexerState(state: IndexingState?) {
if (state is IndexingState.Completed && state.error == null) {
L.d("Received ok response")
val binding = requireBinding()
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
val binding = requireBinding()
updateFabVisibility(binding, songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (shouldHideAllFabs(binding, songs, isFastScrolling)) {
L.d("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
forceHideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
if (binding.homeShuffleFab.isOrWillBeShown) {
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
L.d("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeShuffleFab.show()
}
})
} else {
L.d("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
L.d("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
L.d("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeNewPlaylistFab.show()
}
})
} else {
L.d("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun shouldHideAllFabs(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean
) =
binding.exploreNavHost.findNavController().currentDestination?.id != R.id.home_fragment ||
sheetRising == true ||
songs.isEmpty() ||
isFastScrolling
private fun forceHideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
val binding = requireBinding()
binding.mainScrim.isInvisible = !open
binding.sheetScrim.isInvisible = !open
}
private fun handleShow(show: Show?) {
when (show) {
is Show.SongAlbumDetails,
@ -546,6 +343,13 @@ class MainFragment :
homeModel.showOuter.consume()
}
private fun handleSpeedDialState(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
requireBinding().mainScrim.isVisible = open
requireBinding().sheetScrim.isVisible = open
}
private fun updateSong(song: Song?) {
if (song != null) {
tryShowSheets()
@ -556,7 +360,7 @@ class MainFragment :
private fun handlePanel(panel: OpenPanel?) {
if (panel == null) return
L.d("Trying to update panel to $panel")
logD("Trying to update panel to $panel")
when (panel) {
OpenPanel.MAIN -> tryClosePlaybackPanel()
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
@ -572,7 +376,7 @@ class MainFragment :
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
L.d("Expanding playback sheet")
logD("Expanding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return
}
@ -583,7 +387,7 @@ class MainFragment :
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can shown.
L.d("Collapsing queue sheet")
logD("Collapsing queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
@ -594,7 +398,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed.
L.d("Collapsing playback and queue sheets")
logD("Collapsing playback and queue sheets")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -620,7 +424,7 @@ class MainFragment :
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
L.d("Unhiding and enabling playback sheet")
logD("Unhiding and enabling playback sheet")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed
@ -641,7 +445,7 @@ class MainFragment :
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
L.d("Hiding and disabling playback and queue sheets")
logD("Hiding and disabling playback and queue sheets")
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply {
@ -660,49 +464,19 @@ class MainFragment :
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
) : OnBackPressedCallback(false) {
override fun handleOnBackStarted(backEvent: BackEventCompat) {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).startBackProgress(backEvent)
}
if (playbackSheetShown()) {
playbackSheetBehavior.startBackProgress(backEvent)
return
}
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).updateBackProgress(backEvent)
return
}
if (playbackSheetShown()) {
playbackSheetBehavior.updateBackProgress(backEvent)
return
}
}
override fun handleOnBackPressed() {
// If expanded, collapse the queue sheet first.
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).handleBackInvoked()
unlikelyToBeNull(queueSheetBehavior).state =
BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed queue sheet")
return
}
// If expanded, collapse the playback sheet next.
if (playbackSheetShown()) {
playbackSheetBehavior.handleBackInvoked()
return
}
}
override fun handleOnBackCancelled() {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).cancelBackProgress()
return
}
if (playbackSheetShown()) {
playbackSheetBehavior.cancelBackProgress()
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed playback sheet")
return
}
}
@ -725,7 +499,7 @@ class MainFragment :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (detailModel.dropPlaylistEdit()) {
L.d("Dropped playlist edits")
logD("Dropped playlist edits")
}
}
@ -738,7 +512,7 @@ class MainFragment :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (listModel.dropSelection()) {
L.d("Dropped selection")
logD("Dropped selection")
}
}
@ -747,11 +521,11 @@ class MainFragment :
}
}
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val binding = requireBinding()
if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
if (homeModel.speedDialOpen.value) {
homeModel.setSpeedDialOpen(false)
}
}
@ -761,11 +535,7 @@ class MainFragment :
}
private companion object {
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
val SPEED_DIAL_OVERLAY_ANIMATION_DURATION_FIELD: Field by
lazyReflectedField(SpeedDialOverlayLayout::class, "mAnimationDuration")
}
}

View file

@ -19,34 +19,45 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information about an [Album].
@ -54,17 +65,60 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class AlbumDetailFragment : DetailFragment<Album, Song>() {
class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs()
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
private val albumListAdapter = AlbumDetailListAdapter(this)
override fun getDetailListAdapter() = albumListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.albumSongList.value[it - 1]
item is Divider || item is Header || item is Disc
} else {
true
}
}
}
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbum(args.albumUid)
@ -82,6 +136,8 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.albumSongInstructions.consume()
@ -91,68 +147,34 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
playbackModel.play(item, detailModel.playInAlbumWith)
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
}
override fun onNavigateToParentArtist() {
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
private fun updateAlbum(album: Album?) {
if (album == null) {
L.d("No album to show, navigating away")
logD("No album to show, navigating away")
findNavController().navigateUp()
return
}
val binding = requireBinding()
val context = requireContext()
val name = album.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = album.releaseType.resolve(context)
binding.detailName.text = name
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener {
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
}
private fun updateList(list: List<Item>) {
@ -163,7 +185,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val binding = requireBinding()
when (show) {
is Show.SongDetails -> {
L.d("Navigating to ${show.song}")
logD("Navigating to ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
}
@ -172,11 +194,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// fragment should be launched otherwise.
is Show.SongAlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
L.d("Navigating to a ${show.song} in this album")
logD("Navigating to a ${show.song} in this album")
scrollToAlbumSong(show.song)
detailModel.toShow.consume()
} else {
L.d("Navigating to the album of ${show.song}")
logD("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -186,27 +208,27 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// detail fragment.
is Show.AlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
L.d("Navigating to the top of this album")
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume()
} else {
L.d("Navigating to ${show.album}")
logD("Navigating to ${show.album}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
}
}
is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}")
logD("Navigating to ${show.artist}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}")
logD("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}")
logD("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
}
@ -249,7 +271,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val directions =
when (decision) {
is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist")
logD("Adding ${decision.songs.size} songs to a playlist")
AlbumDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -278,11 +300,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}
@ -296,14 +318,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
if (pos != -1) {
// Only scroll if the song is within this album.
val binding = requireBinding()
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
// collapsed appbar), so we need to collapse the appbar if that's the case.
binding.detailAppbar.setExpanded(false)
if (!binding.detailRecycler.canScroll()) {
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
// kicks in and creates a weird bounce effect.
return
}
binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end.
@ -326,9 +340,12 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
// If the recyclerview can scroll, its certain that it will have to scroll to
// correctly center the playing item, so make sure that the Toolbar is lifted in
// that case.
binding.detailAppbar.isLifted = binding.detailRecycler.canScroll()
}
}
}
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
}

View file

@ -19,33 +19,44 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import androidx.core.view.isVisible
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information about an [Artist].
@ -53,17 +64,63 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class ArtistDetailFragment : DetailFragment<Artist, Music>() {
class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
private val artistListAdapter = ArtistDetailListAdapter(this)
override fun getDetailListAdapter() = artistListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.artistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtist(args.artistUid)
@ -81,6 +138,8 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.artistSongInstructions.consume()
@ -94,10 +153,6 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
}
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onOpenMenu(item: Music) {
when (item) {
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
@ -106,75 +161,26 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
}
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
}
private fun updateArtist(artist: Artist?) {
if (artist == null) {
L.d("No artist to show, navigating away")
logD("No artist to show, navigating away")
findNavController().navigateUp()
return
}
val binding = requireBinding()
val context = requireContext()
val name = artist.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(artist)
binding.detailType.text = context.getString(R.string.lbl_artist)
binding.detailName.text = name
// Song and album counts map to the info
binding.detailInfo.text =
context.getString(
R.string.fmt_two,
if (artist.explicitAlbums.isNotEmpty()) {
context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
context.getString(R.string.def_song_count)
})
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton?.isVisible = true
binding.detailShuffleButton?.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
L.d("Artist is empty, disabling genres and playback")
binding.detailSubhead.isVisible = false
binding.detailPlayButton?.isEnabled = false
binding.detailShuffleButton?.isEnabled = false
}
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
}
private fun updateList(list: List<Item>) {
@ -185,14 +191,14 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
val binding = requireBinding()
when (show) {
is Show.SongDetails -> {
L.d("Navigating to ${show.song}")
logD("Navigating to ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
}
// Songs should be shown in their album, not in their artist.
is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}")
logD("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -200,7 +206,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
// Launch a new detail view for an album, even if it is part of
// this artist.
is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}")
logD("Navigating to ${show.album}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
}
@ -209,22 +215,22 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
// scroll back to the top. Otherwise launch a new detail view.
is Show.ArtistDetails -> {
if (show.artist == detailModel.currentArtist.value) {
L.d("Navigating to the top of this artist")
logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume()
} else {
L.d("Navigating to ${show.artist}")
logD("Navigating to ${show.artist}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
}
}
is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}")
logD("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}")
logD("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
}
@ -268,7 +274,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
val directions =
when (decision) {
is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist")
logD("Adding ${decision.songs.size} songs to a playlist")
ArtistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -309,7 +315,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
is PlaybackDecision.PlayFromArtist ->
error("Unexpected playback decision $decision")
is PlaybackDecision.PlayFromGenre -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}

View file

@ -1,116 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* ContinuousAppBarLayoutBehavior.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
class ContinuousAppBarLayoutBehavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
private var recycler: RecyclerView? = null
private var pointerId = -1
private var velocityTracker: VelocityTracker? = null
override fun onInterceptTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onInterceptTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
findRecyclerView(child).stopScroll()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
return consumed
}
override fun onTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_UP -> {
findRecyclerView(child).fling(0, getYVelocity(ev))
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
velocityTracker?.addMovement(ev)
return consumed
}
private fun ensureVelocityTracker() {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
}
private fun getYVelocity(event: MotionEvent): Int {
velocityTracker?.let {
it.addMovement(event)
it.computeCurrentVelocity(FLING_UNITS)
return -it.getYVelocity(pointerId).toInt()
}
return 0
}
private fun findRecyclerView(child: AppBarLayout): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler =
(child.parent as ViewGroup).findViewById<RecyclerView>(child.liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
companion object {
private const val FLING_UNITS = 1000 // copied from base class
}
}

View file

@ -0,0 +1,169 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailAppBarLayout.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
/**
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
private var titleShown: Boolean? = null
private var titleAnimator: ValueAnimator? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isInEditMode) {
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
}
}
private fun findTitleView(): TextView {
val titleView = titleView
if (titleView != null) {
return titleView
}
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.
val newTitleView =
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
// We can never properly initialize the title view's state before draw time,
// so we just set it's alpha to 0f to produce a less jarring initialization
// animation.
alpha = 0f
}
this.titleView = newTitleView
return newTitleView
}
private fun findRecyclerView(): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
private fun setTitleVisibility(visible: Boolean) {
if (titleShown == visible) return
titleShown = visible
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
// the title view's alpha instead of the AppBarLayout's elevation.
val titleView = findTitleView()
val from: Float
val to: Float
if (visible) {
from = 0f
to = 1f
} else {
from = 1f
to = 0f
}
if (titleView.alpha == to) {
// Nothing to do
return
}
logD("Changing title visibility [from: $from to: $to]")
titleAnimator?.cancel()
titleAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float }
duration =
if (titleShown == true) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
start()
}
}
class Behavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
val appBarLayout = child as DetailAppBarLayout
val recycler = appBarLayout.findRecyclerView()
// Title should be visible if we are no longer showing the top item
// (i.e the header)
appBarLayout.setTitleVisibility(
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
}
}
private companion object {
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
}
}

View file

@ -1,132 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* DetailFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.transition.MaterialSharedAxis
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
abstract class DetailFragment<P : MusicParent, C : Music> :
ListFragment<C, FragmentDetailBinding>(),
DetailListAdapter.Listener<C>,
AppBarLayout.OnOffsetChangedListener {
protected val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
private var spacingSmall = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
abstract fun getDetailListAdapter(): DetailListAdapter
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailAppbar.addOnOffsetChangedListener(this)
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@DetailFragment)
overrideOnOverflowMenuClick { onOpenParentMenu() }
}
binding.detailRecycler.apply {
adapter = getDetailListAdapter()
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.artistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is PlainDivider || item is PlainHeader
} else {
true
}
}
}
spacingSmall = requireContext().getDimenPixels(R.dimen.spacing_small)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailAppbar.removeOnOffsetChangedListener(this)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
val binding = requireBinding()
val range = appBarLayout.totalScrollRange
val ratio = abs(verticalOffset.toFloat()) / range.toFloat()
val outRatio = min(ratio * 2, 1f)
val detailHeader = binding.detailHeader
detailHeader.scaleX = 1 - 0.2f * outRatio / (5f / 3f)
detailHeader.scaleY = 1 - 0.2f * outRatio / (5f / 3f)
detailHeader.alpha = 1 - outRatio
val inRatio = max(ratio - 0.5f, 0f) * 2
val detailContent = binding.detailToolbarContent
detailContent.alpha = inRatio
detailContent.translationY = spacingSmall * (1 - inRatio)
// Enable fast scrolling once fully collapsed
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
}
abstract fun onOpenParentMenu()
}

View file

@ -1,241 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* DetailGenerator.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.ReleaseType
import timber.log.Timber as L
interface DetailGenerator {
fun any(uid: Music.UID): Detail<out MusicParent>?
fun album(uid: Music.UID): Detail<Album>?
fun artist(uid: Music.UID): Detail<Artist>?
fun genre(uid: Music.UID): Detail<Genre>?
fun playlist(uid: Music.UID): Detail<Playlist>?
fun attach()
fun release()
interface Factory {
fun create(invalidator: Invalidator): DetailGenerator
}
interface Invalidator {
fun invalidate(type: MusicType, replace: Int?)
}
}
class DetailGeneratorFactoryImpl
@Inject
constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) :
DetailGenerator.Factory {
override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator =
DetailGeneratorImpl(invalidator, listSettings, musicRepository)
}
private class DetailGeneratorImpl(
private val invalidator: DetailGenerator.Invalidator,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository
) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener {
override fun attach() {
listSettings.registerListener(this)
musicRepository.addUpdateListener(this)
}
override fun onAlbumSongSortChanged() {
super.onAlbumSongSortChanged()
invalidator.invalidate(MusicType.ALBUMS, -1)
}
override fun onArtistSongSortChanged() {
super.onArtistSongSortChanged()
invalidator.invalidate(MusicType.ARTISTS, -1)
}
override fun onGenreSongSortChanged() {
super.onGenreSongSortChanged()
invalidator.invalidate(MusicType.GENRES, -1)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary) {
invalidator.invalidate(MusicType.ALBUMS, null)
invalidator.invalidate(MusicType.ARTISTS, null)
invalidator.invalidate(MusicType.GENRES, null)
}
if (changes.userLibrary) {
invalidator.invalidate(MusicType.PLAYLISTS, null)
}
}
override fun release() {
listSettings.unregisterListener(this)
musicRepository.removeUpdateListener(this)
}
override fun any(uid: Music.UID): Detail<out MusicParent>? {
val music = musicRepository.find(uid) ?: return null
return when (music) {
is Album -> album(uid)
is Artist -> artist(uid)
is Genre -> genre(uid)
is Playlist -> playlist(uid)
else -> null
}
}
override fun album(uid: Music.UID): Detail<Album>? {
val album = musicRepository.library?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc }
val section =
if (discs.size > 1) {
DetailSection.Discs(discs)
} else {
DetailSection.Songs(songs)
}
return Detail(album, listOf(section))
}
override fun artist(uid: Music.UID): Detail<Artist>? {
val artist = musicRepository.library?.findArtist(uid) ?: return null
val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE
ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES
null ->
when (it.releaseType) {
is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS
is ReleaseType.EP -> DetailSection.Albums.Category.EPS
is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES
is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS
is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS
is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES
is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES
is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS
}
}
}
if (artist.implicitAlbums.isNotEmpty()) {
L.d("Implicit albums present, adding to list")
grouping[DetailSection.Albums.Category.APPEARANCES] =
artist.implicitAlbums.toMutableList()
}
val sections =
grouping.mapTo(mutableListOf<DetailSection>()) { (category, albums) ->
DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums))
}
if (artist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs))
sections.add(songs)
}
return Detail(artist, sections)
}
override fun genre(uid: Music.UID): Detail<Genre>? {
val genre = musicRepository.library?.findGenre(uid) ?: return null
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs))
}
override fun playlist(uid: Music.UID): Detail<Playlist>? {
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs))
}
return Detail(playlist, listOf())
}
private companion object {
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
data class Detail<P : MusicParent>(val parent: P, val sections: List<DetailSection>)
sealed interface DetailSection {
val order: Int
val stringRes: Int
abstract class PlainSection<T : Music> : DetailSection {
abstract val items: List<T>
}
data class Artists(override val items: List<Artist>) : PlainSection<Artist>() {
override val order = 0
override val stringRes = R.string.lbl_artists
}
data class Albums(val category: Category, override val items: List<Album>) :
PlainSection<Album>() {
override val order = 1 + category.ordinal
override val stringRes = category.stringRes
enum class Category(@StringRes val stringRes: Int) {
ALBUMS(R.string.lbl_albums),
EPS(R.string.lbl_eps),
SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks),
DJ_MIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes),
DEMOS(R.string.lbl_demos),
APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group)
}
}
data class Songs(override val items: List<Song>) : PlainSection<Song>() {
override val order = 12
override val stringRes = R.string.lbl_songs
}
data class Discs(val discs: Map<Disc?, List<Song>>) : DetailSection {
override val order = 13
override val stringRes = R.string.lbl_songs
}
}

View file

@ -18,41 +18,43 @@
package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscDivider
import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
@ -66,11 +68,10 @@ class DetailViewModel
constructor(
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator {
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.UpdateListener {
private val _toShow = MutableEvent<Show>()
/**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/
@ -79,34 +80,30 @@ constructor(
// --- SONG ---
private val _currentSong = MutableStateFlow<Song?>(null)
private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
/** The current properties of [currentSong]. Empty if nothing to show. */
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>>
get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions
@ -122,25 +119,27 @@ constructor(
// --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?>
get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions
/** The current [Sort] used for [Song]s in [artistSongList]. */
val artistSongSort: Sort
var artistSongSort: Sort
get() = listSettings.artistSongSort
set(value) {
listSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort.
currentArtist.value?.let { refreshArtistList(it, true) }
}
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
val playInArtistWith
@ -149,25 +148,27 @@ constructor(
// --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?>
get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions
/** The current [Sort] used for [Song]s in [genreSongList]. */
val genreSongSort: Sort
var genreSongSort: Sort
get() = listSettings.genreSongSort
set(value) {
listSettings.genreSongSort = value
// Refresh the genre list to reflect the new sort.
currentGenre.value?.let { refreshGenreList(it, true) }
}
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
val playInGenreWith
@ -176,24 +177,20 @@ constructor(
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
@ -207,35 +204,54 @@ constructor(
playbackSettings.inParentPlaybackMode
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
private val detailGenerator = detailGeneratorFactory.create(this)
init {
detailGenerator.attach()
musicRepository.addUpdateListener(this)
}
override fun onCleared() {
detailGenerator.release()
musicRepository.removeUpdateListener(this)
}
override fun invalidate(type: MusicType, replace: Int?) {
when (type) {
MusicType.ALBUMS -> {
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
override fun onMusicChanges(changes: MusicRepository.Changes) {
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
// in the UI.
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
val song = currentSong.value
if (song != null) {
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
}
MusicType.ARTISTS -> {
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
refreshDetail(
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
logD("Updated album to ${currentAlbum.value}")
}
MusicType.GENRES -> {
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value =
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
logD("Updated artist to ${currentArtist.value}")
}
MusicType.PLAYLISTS -> {
refreshPlaylist(currentPlaylist.value?.uid ?: return)
val genre = currentGenre.value
if (genre != null) {
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
}
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value
if (playlist != null) {
_currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
logD("Updated playlist to ${currentPlaylist.value}")
}
else -> error("Unexpected music type $type")
}
}
@ -312,23 +328,23 @@ constructor(
private fun showImpl(show: Show) {
val existing = toShow.flow.value
if (existing != null) {
L.d("Already have pending show command $existing, ignoring $show")
logD("Already have pending show command $existing, ignoring $show")
return
}
_toShow.put(show)
}
/**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
* the new [Song].
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSong(uid: Music.UID) {
L.d("Opening song $uid")
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
logD("Opening song $uid")
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) {
L.w("Given song UID was invalid")
logW("Given song UID was invalid")
}
}
@ -339,14 +355,11 @@ constructor(
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/
fun setAlbum(uid: Music.UID) {
L.d("Opening album $uid")
if (uid === _currentAlbum.value?.uid) {
return
}
val album = detailGenerator.album(uid)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
logD("Opening album $uid")
_currentAlbum.value =
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
if (_currentAlbum.value == null) {
L.w("Given album UID was invalid")
logW("Given album UID was invalid")
}
}
@ -357,6 +370,7 @@ constructor(
*/
fun applyAlbumSongSort(sort: Sort) {
listSettings.albumSongSort = sort
_currentAlbum.value?.let { refreshAlbumList(it, true) }
}
/**
@ -366,12 +380,12 @@ constructor(
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/
fun setArtist(uid: Music.UID) {
L.d("Opening artist $uid")
if (uid === _currentArtist.value?.uid) {
return
logD("Opening artist $uid")
_currentArtist.value =
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
if (_currentArtist.value == null) {
logW("Given artist UID was invalid")
}
val artist = detailGenerator.artist(uid)
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
}
/**
@ -381,6 +395,7 @@ constructor(
*/
fun applyArtistSongSort(sort: Sort) {
listSettings.artistSongSort = sort
_currentArtist.value?.let { refreshArtistList(it, true) }
}
/**
@ -390,12 +405,12 @@ constructor(
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/
fun setGenre(uid: Music.UID) {
L.d("Opening genre $uid")
if (uid === _currentGenre.value?.uid) {
return
logD("Opening genre $uid")
_currentGenre.value =
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
if (_currentGenre.value == null) {
logW("Given genre UID was invalid")
}
val genre = detailGenerator.genre(uid)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
}
/**
@ -405,6 +420,7 @@ constructor(
*/
fun applyGenreSongSort(sort: Sort) {
listSettings.genreSongSort = sort
_currentGenre.value?.let { refreshGenreList(it, true) }
}
/**
@ -414,19 +430,20 @@ constructor(
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/
fun setPlaylist(uid: Music.UID) {
L.d("Opening playlist $uid")
if (uid === _currentPlaylist.value?.uid) {
return
logD("Opening playlist $uid")
_currentPlaylist.value =
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
if (_currentPlaylist.value == null) {
logW("Given playlist UID was invalid")
}
refreshPlaylist(uid)
}
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
fun startPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
L.d("Starting playlist edit")
logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs
refreshPlaylist(playlist.uid)
refreshPlaylistList(playlist)
}
/**
@ -436,13 +453,12 @@ constructor(
fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = _editedPlaylist.value ?: return
L.d("Committing playlist edits")
logD("Committing playlist edits")
viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough.
// Think of a better way to handle this state.
_editedPlaylist.value = null
refreshPlaylist(playlist.uid)
}
}
@ -458,8 +474,9 @@ constructor(
// Nothing to do.
return false
}
logD("Discarding playlist edits")
_editedPlaylist.value = null
refreshPlaylist(playlist.uid)
refreshPlaylistList(playlist)
return true
}
@ -471,7 +488,7 @@ constructor(
fun applyPlaylistSongSort(sort: Sort) {
val playlist = _currentPlaylist.value ?: return
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
}
/**
@ -484,15 +501,15 @@ constructor(
fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 1
val realTo = to - 1
val realFrom = from - 2
val realTo = to - 2
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false
}
L.d("Moving playlist song from $realFrom [$from] to $realTo [$to]")
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
return true
}
@ -504,134 +521,205 @@ constructor(
fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 1
val realAt = at - 2
if (realAt !in editedPlaylist.indices) {
return
}
L.d("Removing playlist song at $realAt [$at]")
logD("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist
refreshPlaylist(
playlist.uid,
refreshPlaylistList(
playlist,
if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1)
} else {
L.d("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 1, 3)
logD("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 2, 3)
})
}
private fun refreshAudioInfo(song: Song) {
_currentSongProperties.value = buildList {
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
logD("Refreshing audio info")
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioPropertiesFactory.extract(song)
yield()
logD("Updating audio info to $info")
_songAudioProperties.value = info
}
song.disc?.let {
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
}
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
add(
SongProperty(
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
}
}
}
private inline fun <T : MusicParent> refreshDetail(
detail: Detail<T>?,
parent: MutableStateFlow<T?>,
list: MutableStateFlow<List<Item>>,
instructions: MutableEvent<UpdateInstructions>,
replace: Int?,
songHeader: (Int) -> PlainHeader = { SortHeader(it) }
) {
if (detail == null) {
parent.value = null
return
}
val newList = mutableListOf<Item>()
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
for ((i, section) in detail.sections.withIndex()) {
val items =
when (section) {
is DetailSection.PlainSection<*> -> {
val header =
if (section is DetailSection.Songs) songHeader(section.stringRes)
else BasicHeader(section.stringRes)
if (newList.isNotEmpty()) {
newList.add(PlainDivider(header))
}
newList.add(header)
section.items
}
is DetailSection.Discs -> {
val header = SortHeader(section.stringRes)
if (newList.isNotEmpty()) {
newList.add(PlainDivider(header))
}
newList.add(header)
buildList<Item> {
for (entry in section.discs) {
val discHeader = DiscHeader(inner = entry.key)
if (isNotEmpty()) {
add(DiscDivider(discHeader))
}
add(discHeader)
addAll(entry.value)
}
}
}
}
// Currently only the final section (songs, which can be sorted) are invalidatable
// and thus need to be replaced.
if (replace == -1 && i == detail.sections.lastIndex) {
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album list")
val list = mutableListOf<Item>()
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
newInstructions = UpdateInstructions.Replace(newList.size)
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
newList.addAll(items)
// To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs)
val byDisc = songs.groupBy { it.disc }
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
list.add(DiscHeader(entry.key))
list.addAll(entry.value)
}
} else {
// Album only has one disc, don't add any redundant headers
list.addAll(songs)
}
parent.value = detail.parent
instructions.put(newInstructions)
list.value = newList
logD("Update album list to ${list.size} items with $instructions")
_albumSongInstructions.put(instructions)
_albumSongList.value = list
}
private fun refreshPlaylist(
uid: Music.UID,
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist list")
val list = mutableListOf<Item>()
val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into an easier
// "AlbumGrouping" enum that will automatically group and sort
// the artist's albums.
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
null ->
when (it.releaseType) {
is ReleaseType.Album -> AlbumGrouping.ALBUMS
is ReleaseType.EP -> AlbumGrouping.EPS
is ReleaseType.Single -> AlbumGrouping.SINGLES
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
is ReleaseType.Demo -> AlbumGrouping.DEMOS
}
}
}
if (artist.implicitAlbums.isNotEmpty()) {
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
// inherits list, we can cast upwards and save a copy by directly inserting the
// implicit album list into the mapping.
logD("Implicit albums present, adding to list")
@Suppress("UNCHECKED_CAST")
(grouping as MutableMap<AlbumGrouping, Collection<Album>>)[AlbumGrouping.APPEARANCES] =
artist.implicitAlbums
}
logD("Release groups for this artist: ${grouping.keys}")
for (entry in grouping.entries) {
val header = BasicHeader(entry.key.headerTitleRes)
list.add(Divider(header))
list.add(header)
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
}
// Artists may not be linked to any songs, only include a header entry if we have any.
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
}
list.addAll(artistSongSort.songs(artist.songs))
}
logD("Updating artist list to ${list.size} items with $instructions")
_artistSongInstructions.put(instructions)
_artistSongList.value = list.toList()
}
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre list")
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
val artistHeader = BasicHeader(R.string.lbl_artists)
list.add(Divider(artistHeader))
list.add(artistHeader)
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
val songHeader = SortHeader(R.string.lbl_songs)
list.add(Divider(songHeader))
list.add(songHeader)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced alongside the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
list.addAll(genreSongSort.songs(genre.songs))
logD("Updating genre list to ${list.size} items with $instructions")
_genreSongInstructions.put(instructions)
_genreSongList.value = list
}
private fun refreshPlaylistList(
playlist: Playlist,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
L.d("Refreshing playlist list")
val edited = editedPlaylist.value
if (edited == null) {
val playlist = detailGenerator.playlist(uid)
refreshDetail(
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) {
EditHeader(it)
}
return
}
logD("Refreshing playlist list")
val list = mutableListOf<Item>()
if (edited.isNotEmpty()) {
val songs = editedPlaylist.value ?: playlist.songs
if (songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
list.addAll(edited)
list.addAll(songs)
}
logD("Updating playlist list to ${list.size} items with $instructions")
_playlistSongInstructions.put(instructions)
_playlistSongList.value = list
}
/**
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
*
* @param headerTitleRes The title string resource to use for a header created out of an
* instance of this enum.
*/
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
ALBUMS(R.string.lbl_albums),
EPS(R.string.lbl_eps),
SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks),
DJMIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes),
DEMOS(R.string.lbl_demos),
APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group),
}
private companion object {
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
/**

View file

@ -19,32 +19,44 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import androidx.core.view.isVisible
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information for a particular [Genre].
@ -52,21 +64,65 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class GenreDetailFragment : DetailFragment<Genre, Music>() {
class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs()
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
private val genreListAdapter = GenreDetailListAdapter(this)
override fun getDetailListAdapter() = genreListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.genreSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenre(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateGenre)
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreSongList, ::updateList)
collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu)
@ -80,6 +136,8 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.genreSongInstructions.consume()
@ -93,10 +151,6 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
}
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onOpenMenu(item: Music) {
when (item) {
is Artist -> listModel.openMenu(R.menu.parent, item)
@ -105,45 +159,26 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
}
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
}
private fun updateGenre(genre: Genre?) {
private fun updatePlaylist(genre: Genre?) {
if (genre == null) {
L.d("No genre to show, navigating away")
logD("No genre to show, navigating away")
findNavController().navigateUp()
return
}
val binding = requireBinding()
val context = requireContext()
val name = genre.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(genre)
binding.detailType.text = context.getString(R.string.lbl_genre)
binding.detailName.text = genre.name.resolve(context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
context.getString(
R.string.fmt_two,
context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
}
private fun updateList(list: List<Item>) {
@ -153,7 +188,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
L.d("Navigating to ${show.song}")
logD("Navigating to ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
}
@ -161,7 +196,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}")
logD("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -169,29 +204,29 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
// If the album matches, no need to do anything. Otherwise launch a new
// detail fragment.
is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}")
logD("Navigating to ${show.album}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
}
// Always launch a new ArtistDetailFragment.
is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}")
logD("Navigating to ${show.artist}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}")
logD("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}")
logD("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.GenreDetails -> {
L.d("Navigated to this genre")
logD("Navigated to this genre")
detailModel.toShow.consume()
}
is Show.PlaylistDetails -> {
@ -232,7 +267,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
val directions =
when (decision) {
is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist")
logD("Adding ${decision.songs.size} songs to a playlist")
GenreDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -271,7 +306,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")

View file

@ -19,41 +19,51 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
* A [ListFragment] that shows information for a particular [Playlist].
@ -62,17 +72,35 @@ import timber.log.Timber as L
*/
@AndroidEntryPoint
class PlaylistDetailFragment :
DetailFragment<Playlist, Song>(), PlaylistDetailListAdapter.Listener {
ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
PlaylistDetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
// Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun getDetailListAdapter() = playlistListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
@ -82,31 +110,52 @@ class PlaylistDetailFragment :
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
L.w("No URI returned from file picker")
logW("No URI returned from file picker")
return@registerForActivityResult
}
L.d("Received playlist URI $uri")
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(binding.detailRecycler)
binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this)
}
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.playlistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylist(args.playlistUid)
collectImmediately(
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistSongList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
collect(detailModel.toShow.flow, ::handleShow)
@ -161,97 +210,41 @@ class PlaylistDetailFragment :
playbackModel.play(item, detailModel.playInPlaylistWith)
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
override fun onOpenParentMenu() {
listModel.openMenu(
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
}
private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) {
private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) {
// Playlist we were showing no longer exists.
findNavController().navigateUp()
return
}
val binding = requireBinding()
binding.detailToolbarTitle.text = playlist.name.resolve(requireContext())
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title =
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
if (editedPlaylist != null) {
L.d("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
L.d("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton?.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarPlay.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton?.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarShuffle.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
playlistHeaderAdapter.setParent(playlist)
}
private fun updateList(list: List<Item>) {
@ -260,10 +253,11 @@ class PlaylistDetailFragment :
private fun updateEditedList(editedPlaylist: List<Song>?) {
playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
listModel.dropSelection()
if (editedPlaylist != null) {
L.d("Updating save button state")
logD("Updating save button state")
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
}
@ -275,38 +269,38 @@ class PlaylistDetailFragment :
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
L.d("Navigating to ${show.song}")
logD("Navigating to ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
}
is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}")
logD("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
}
is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}")
logD("Navigating to ${show.album}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
}
is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}")
logD("Navigating to ${show.artist}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}")
logD("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}")
logD("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.PlaylistDetails -> {
L.d("Navigated to this playlist")
logD("Navigated to this playlist")
detailModel.toShow.consume()
}
is Show.GenreDetails -> {
@ -347,7 +341,7 @@ class PlaylistDetailFragment :
val directions =
when (decision) {
is PlaylistDecision.Import -> {
L.d("Importing playlist")
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
@ -357,7 +351,7 @@ class PlaylistDetailFragment :
return
}
is PlaylistDecision.Rename -> {
L.d("Renaming ${decision.playlist}")
logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
@ -365,15 +359,15 @@ class PlaylistDetailFragment :
decision.reason)
}
is PlaylistDecision.Export -> {
L.d("Exporting ${decision.playlist}")
logD("Exporting ${decision.playlist}")
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
}
is PlaylistDecision.Delete -> {
L.d("Deleting ${decision.playlist}")
logD("Deleting ${decision.playlist}")
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
}
is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist")
logD("Adding ${decision.songs.size} songs to a playlist")
PlaylistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -399,11 +393,11 @@ class PlaylistDetailFragment :
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> {
L.d("Launching play from artist dialog for $decision")
logD("Launching play from artist dialog for $decision")
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}
@ -414,15 +408,15 @@ class PlaylistDetailFragment :
val id =
when {
detailModel.editedPlaylist.value != null -> {
L.d("Currently editing playlist, showing edit toolbar")
logD("Currently editing playlist, showing edit toolbar")
R.id.detail_edit_toolbar
}
listModel.selected.value.isNotEmpty() -> {
L.d("Currently selecting, showing selection toolbar")
logD("Currently selecting, showing selection toolbar")
R.id.detail_selection_toolbar
}
else -> {
L.d("Using normal toolbar")
logD("Using normal toolbar")
R.id.detail_normal_toolbar
}
}

View file

@ -18,7 +18,9 @@
package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
@ -30,10 +32,17 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Song
import timber.log.Timber as L
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid)
detailModel.toShow.consume()
collectImmediately(detailModel.currentSong, ::updateSong)
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
}
private fun updateSong(song: Song?) {
L.d("No song to show, navigating away")
private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) {
logD("No song to show, navigating away")
findNavController().navigateUp()
return
}
if (info != null) {
val context = requireContext()
detailAdapter.update(
buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
}
song.disc?.let {
val formattedNumber = getString(R.string.fmt_number, it.number)
val zipped =
if (it.name != null) {
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
} else {
formattedNumber
}
add(SongProperty(R.string.lbl_disc, zipped))
}
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
info.resolvedMimeType.resolveName(context)?.let {
add(SongProperty(R.string.lbl_format, it))
}
add(
SongProperty(
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
info.bitrateKbps?.let {
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
}
info.sampleRateHz?.let {
add(
SongProperty(
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
}
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
}
},
UpdateInstructions.Replace(0))
}
}
private fun updateSongProperties(songProperties: List<SongProperty>) {
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
private fun <T : Music> T.zipName(context: Context): String {
val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
name.resolve(context)
}
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
}

View file

@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,13 +23,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the
@ -56,10 +57,10 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val library = musicRepository.library ?: return
val deviceLibrary = musicRepository.deviceLibrary ?: return
// Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(library)
L.d("Updated artist choices: ${_artistChoices.value}")
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
logD("Updated artist choices: ${_artistChoices.value}")
}
/**
@ -68,20 +69,20 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
*/
fun setArtistChoiceUid(itemUid: Music.UID) {
L.d("Opening navigation choices for $itemUid")
logD("Opening navigation choices for $itemUid")
// Support Songs and Albums, which have parent artists.
_artistChoices.value =
when (val music = musicRepository.find(itemUid)) {
is Song -> {
L.d("Creating navigation choices for song")
logD("Creating navigation choices for song")
ArtistShowChoices.FromSong(music)
}
is Album -> {
L.d("Creating navigation choices for album")
logD("Creating navigation choices for album")
ArtistShowChoices.FromAlbum(music)
}
else -> {
L.w("Given song/album UID was invalid")
logW("Given song/album UID was invalid")
null
}
}
@ -98,15 +99,16 @@ sealed interface ArtistShowChoices {
val uid: Music.UID
/** The current [Artist] choices. */
val choices: List<Artist>
/** Sanitize this instance with a [Library]. */
fun sanitize(newLibrary: Library): ArtistShowChoices?
/** Sanitize this instance with a [DeviceLibrary]. */
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid
override val choices = song.artists
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findSong(uid)?.let { FromSong(it) }
}
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -114,7 +116,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid
override val choices = album.artists
override fun sanitize(newLibrary: Library) =
override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
}
}

View file

@ -32,10 +32,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
@ -85,7 +85,7 @@ class ShowArtistDialog :
private fun updateChoices(choices: ArtistShowChoices?) {
if (choices == null) {
L.d("No choices to show, navigating away")
logD("No choices to show, navigating away")
findNavController().navigateUp()
return
}

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2023 Auxio Project
* AlbumDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
holder.bind(parent, listener)
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener : DetailHeaderAdapter.Listener {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.name.resolve(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (c) 2023 Auxio Project
* ArtistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.name.resolve(binding.context)
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
if (artist.explicitAlbums.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
binding.context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
binding.context.getString(R.string.def_song_count)
})
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
logD("Artist is empty, disabling genres and playback")
binding.detailSubhead.isVisible = false
binding.detailPlayButton.isEnabled = false
binding.detailShuffleButton.isEnabled = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 Auxio Project
* DetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
private var currentParent: T? = null
final override fun getItemCount() = 1
final override fun onBindViewHolder(holder: VH, position: Int) =
onBindHeader(holder, requireNotNull(currentParent))
/**
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
*
* @param holder The [RecyclerView.ViewHolder] to bind.
* @param parent The current [MusicParent] to bind.
*/
abstract fun onBindHeader(holder: VH, parent: T)
/**
* Update the [MusicParent] shown in the header.
*
* @param parent The new [MusicParent] to show.
*/
fun setParent(parent: T) {
logD("Updating parent [old: $currentParent new: $parent]")
currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
logD("Rebinding parent")
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** A listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
}
private companion object {
val PAYLOAD_UPDATE_HEADER = Any()
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2023 Auxio Project
* GenreDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.name.resolve(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Playlist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
holder.bind(parent, editedPlaylist, listener)
/**
* Indicate to this adapter that editing is ongoing with the current state of the editing
* process. This will make the header immediately update to reflect information about the edited
* playlist.
*/
fun setEditedPlaylist(songs: List<Song>?) {
if (editedPlaylist == songs) {
// Nothing to do.
return
}
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
editedPlaylist = songs
rebindParent()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param editedPlaylist The current edited state of the playlist, if it exists.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(
playlist: Playlist,
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
if (editedPlaylist != null) {
logD("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
logD("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton.apply {
isEnabled = playable
setOnClickListener { listener.onPlay() }
}
binding.detailShuffleButton.apply {
isEnabled = playable
setOnClickListener { listener.onShuffle() }
}
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -24,25 +24,21 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.auxio.util.logD
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -56,7 +52,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
when (getItem(position)) {
// Support sub-headers for each disc, and special album songs.
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
@ -64,7 +59,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@ -85,8 +79,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
when {
oldItem is Disc && newItem is Disc ->
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscDivider && newItem is DiscDivider ->
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -102,9 +94,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class DiscHeader(val inner: Disc?) : Header
data class DiscDivider(override val anchor: DiscHeader?) : Divider<DiscHeader>
data class DiscHeader(val inner: Disc?) : Item
/**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
@ -121,10 +111,16 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/
fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner
binding.discNumber.text = disc.resolve(binding.context)
binding.discName.apply {
text = disc?.name
isGone = disc?.name == null
if (disc != null) {
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
binding.discName.apply {
text = disc.name
isGone = disc.name == null
}
} else {
logD("Disc is null, defaulting to no disc")
binding.discNumber.text = binding.context.getString(R.string.def_disc)
binding.discName.isGone = true
}
}
@ -150,42 +146,6 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DiscDividerViewHolder private constructor(divider: MaterialDivider) :
RecyclerView.ViewHolder(divider) {
init {
divider.dividerColor =
divider.context
.getAttrColorCompat(com.google.android.material.R.attr.colorOutlineVariant)
.defaultColor
}
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_DIVIDER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) = DiscDividerViewHolder(MaterialDivider(parent.context))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<DiscDivider>() {
override fun areContentsTheSame(oldItem: DiscDivider, newItem: DiscDivider) =
oldItem.anchor == newItem.anchor
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
* create an instance.

View file

@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
@ -105,7 +104,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -27,17 +27,17 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
/**
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
@ -55,7 +55,7 @@ abstract class DetailListAdapter(
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Implement support for headers and sort headers
is PlainDivider -> DividerViewHolder.VIEW_TYPE
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is PlainDivider && newItem is PlainDivider ->
oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class SortHeader(@StringRes override val titleRes: Int) : PlainHeader
data class SortHeader(@StringRes override val titleRes: Int) : Header
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create

View file

@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.

View file

@ -30,24 +30,25 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
@ -98,9 +99,9 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
// Nothing to do.
return
}
L.d("Updating editing state [old: $isEditing new: $editing]")
logD("Updating editing state [old: $isEditing new: $editing]")
this.isEditing = editing
notifyItemRangeChanged(0, currentList.size, PAYLOAD_EDITING_CHANGED)
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
}
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
@ -141,12 +142,12 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
}
/**
* A [PlainHeader] variant that displays an edit button.
* A [Header] variant that displays an edit button.
*
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class EditHeader(@StringRes override val titleRes: Int) : PlainHeader
data class EditHeader(@StringRes override val titleRes: Int) : Header
/**
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
@ -231,7 +232,8 @@ private constructor(private val binding: ItemEditableSongBinding) :
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0
}

View file

@ -18,26 +18,17 @@
package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
/**
* An adapter for [SongProperty] instances.
@ -62,31 +53,7 @@ class SongPropertyAdapter :
* @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt)
*/
data class SongProperty(@StringRes val name: Int, val value: Value) {
sealed interface Value {
data class MusicName(val music: Music) : Value
data class MusicNames(val name: List<Music>) : Value
data class Number(val value: Int, val subtitle: String?) : Value
data class ItemDate(val date: Date) : Value
data class ItemPath(val path: Path) : Value
data class Size(val sizeBytes: Long) : Value
data class Duration(val durationMs: Long) : Value
data class ItemFormat(val format: Format) : Value
data class Bitrate(val kbps: Int) : Value
data class SampleRate(val hz: Int) : Value
data class Decibels(val value: Float) : Value
}
}
data class SongProperty(@StringRes val name: Int, val value: String) : Item
/**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
@ -98,58 +65,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
fun bind(property: SongProperty) {
val context = binding.context
binding.propertyName.hint = context.getString(property.name)
when (property.value) {
is SongProperty.Value.MusicName -> {
val music = property.value.music
binding.propertyValue.setText(music.name.resolve(context))
}
is SongProperty.Value.MusicNames -> {
val names = property.value.name.resolveNames(context)
binding.propertyValue.setText(names)
}
is SongProperty.Value.Number -> {
val value = context.getString(R.string.fmt_number, property.value.value)
val subtitle = property.value.subtitle
binding.propertyValue.setText(
if (subtitle != null) {
context.getString(R.string.fmt_zipped_names, value, subtitle)
} else {
value
})
}
is SongProperty.Value.ItemDate -> {
val date = property.value.date
binding.propertyValue.setText(date.resolve(context))
}
is SongProperty.Value.ItemPath -> {
val path = property.value.path
binding.propertyValue.setText(path.resolve(context))
}
is SongProperty.Value.Size -> {
val size = property.value.sizeBytes
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
}
is SongProperty.Value.Duration -> {
val duration = property.value.durationMs
binding.propertyValue.setText(duration.formatDurationMs(true))
}
is SongProperty.Value.ItemFormat -> {
val format = property.value.format
binding.propertyValue.setText(format.resolve(context))
}
is SongProperty.Value.Bitrate -> {
val kbps = property.value.kbps
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
}
is SongProperty.Value.SampleRate -> {
val hz = property.value.hz
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
}
is SongProperty.Value.Decibels -> {
val value = property.value.value
binding.propertyValue.setText(value.formatDb(context))
}
}
binding.propertyValue.setText(property.value)
}
companion object {

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
private fun updateAlbum(album: Album?) {
if (album == null) {
L.d("No album to sort, navigating away")
logD("No album to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
private fun updateArtist(artist: Artist?) {
if (artist == null) {
L.d("No artist to sort, navigating away")
logD("No artist to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
private fun updateGenre(genre: Genre?) {
if (genre == null) {
L.d("No genre to sort, navigating away")
logD("No genre to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
private fun updatePlaylist(genre: Playlist?) {
if (genre == null) {
L.d("No genre to sort, navigating away")
logD("No genre to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
package org.oxycblt.auxio.home
import android.content.Context
import android.util.AttributeSet
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding.
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets
}

View file

@ -24,11 +24,9 @@ import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null
private val musicModel: MusicViewModel by viewModels()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_error_info)
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
.setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES)
}

View file

@ -22,10 +22,11 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
@ -37,11 +38,16 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.detail.DetailViewModel
@ -51,28 +57,36 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.PlaylistListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.NamedTabStrategy
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.NoAudioPermissionException
import org.oxycblt.auxio.music.NoMusicException
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
@ -82,7 +96,9 @@ import timber.log.Timber as L
*/
@AndroidEntryPoint
class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
SelectionFragment<FragmentHomeBinding>(),
AppBarLayout.OnOffsetChangedListener,
SpeedDialView.OnActionSelectedListener {
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -95,10 +111,15 @@ class HomeFragment :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
if (savedInstanceState != null) {
// Orientation change will wipe whatever transition we were using prior, which will
// result in no transition when the user navigates back. Make sure we re-initialize
// our transitions.
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1)
if (axis > -1) {
applyAxisTransition(axis)
}
}
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
@ -118,11 +139,11 @@ class HomeFragment :
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
L.w("No URI returned from file picker")
logW("No URI returned from file picker")
return@registerForActivityResult
}
L.d("Received playlist URI $uri")
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
@ -134,6 +155,11 @@ class HomeFragment :
MenuCompat.setGroupDividerEnabled(menu, true)
}
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
binding.homeIndexingProgress.trackColor =
requireContext().getColorCompat(R.color.sel_track).defaultColor
binding.homePager.apply {
// Update HomeViewModel whenever the user swipes through the ViewPager.
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
@ -170,10 +196,25 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@HomeFragment)
setChangeListener(homeModel::setSpeedDialOpen)
}
hideAllFabs()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection)
@ -183,11 +224,37 @@ class HomeFragment :
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
override fun onResume() {
super.onResume()
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
requireBinding().root.rootView.apply {
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
val transition = enterTransition
if (transition is MaterialSharedAxis) {
outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis)
}
super.onSaveInstanceState(outState)
}
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -209,17 +276,18 @@ class HomeFragment :
return when (item.itemId) {
// Handle main actions (Search, Settings, About)
R.id.action_search -> {
L.d("Navigating to search")
logD("Navigating to search")
applyAxisTransition(MaterialSharedAxis.Z)
findNavController().navigateSafe(HomeFragmentDirections.search())
true
}
R.id.action_settings -> {
L.d("Navigating to preferences")
logD("Navigating to preferences")
homeModel.showSettings()
true
}
R.id.action_about -> {
L.d("Navigating to about")
logD("Navigating to about")
homeModel.showAbout()
true
}
@ -239,12 +307,30 @@ class HomeFragment :
true
}
else -> {
L.w("Unexpected menu item selected")
logW("Unexpected menu item selected")
false
}
}
}
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
logD("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close th speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
@ -253,7 +339,7 @@ class HomeFragment :
if (homeModel.currentTabTypes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
L.d("Single tab shown, disabling TabLayout")
logD("Single tab shown, disabling TabLayout")
binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, false)
toolbarParams.scrollFlags = 0
@ -266,7 +352,9 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator(
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
.attach()
}
@ -284,12 +372,14 @@ class HomeFragment :
MusicType.GENRES -> R.id.home_genre_recycler
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
}
updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun handleRecreate(recreate: Unit?) {
if (recreate == null) return
val binding = requireBinding()
L.d("Recreating ViewPager")
logD("Recreating ViewPager")
// Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration.
@ -297,49 +387,104 @@ class HomeFragment :
homeModel.recreateTabs.consume()
}
private fun handleChooseFolders(unit: Unit?) {
if (unit == null) {
return
}
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
homeModel.chooseMusicLocations.consume()
}
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
is IndexingState.Completed -> {
binding.homeIndexingContainer.isInvisible = state.error == null
binding.homeIndexingProgress.isInvisible = state.error != null
binding.homeIndexingError.isInvisible = state.error == null
if (state.error != null) {
binding.homeIndexingContainer.setOnClickListener {
findNavController()
.navigateSafe(HomeFragmentDirections.reportError(state.error))
}
} else {
binding.homeIndexingContainer.setOnClickListener(null)
}
}
is IndexingState.Indexing -> {
binding.homeIndexingContainer.isInvisible = false
binding.homeIndexingProgress.apply {
isInvisible = false
when (state.progress) {
is IndexingProgress.Songs -> {
isIndeterminate = false
progress = state.progress.loaded
max = state.progress.explored
}
is IndexingProgress.Indeterminate -> {
isIndeterminate = true
}
}
}
binding.homeIndexingError.isInvisible = true
}
is IndexingState.Completed -> setupCompleteState(binding, state.error)
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
null -> {
binding.homeIndexingContainer.isInvisible = true
logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
}
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
logD("Received ok response")
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
binding.homeIndexingContainer.visibility = View.INVISIBLE
return
}
logD("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
logD("Showing permission prompt")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingTry.apply {
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(PERMISSION_READ_AUDIO)
}
}
binding.homeIndexingMore.visibility = View.GONE
}
is NoMusicException -> {
logD("Showing no music error")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
binding.homeIndexingMore.visibility = View.GONE
}
else -> {
logD("Showing generic error")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
binding.homeIndexingMore.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
}
}
}
}
}
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingActions.visibility = View.INVISIBLE
when (progress) {
is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
binding.homeIndexingProgress.isIndeterminate = true
}
is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text =
getString(R.string.fmt_indexing, progress.current, progress.total)
binding.homeIndexingProgress.apply {
isIndeterminate = false
max = progress.total
this.progress = progress.current
}
}
}
}
@ -349,14 +494,14 @@ class HomeFragment :
val directions =
when (decision) {
is PlaylistDecision.New -> {
L.d("Creating new playlist")
logD("Creating new playlist")
HomeFragmentDirections.newPlaylist(
decision.songs.map { it.uid }.toTypedArray(),
decision.template,
decision.reason)
}
is PlaylistDecision.Import -> {
L.d("Importing playlist")
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
@ -366,7 +511,7 @@ class HomeFragment :
return
}
is PlaylistDecision.Rename -> {
L.d("Renaming ${decision.playlist}")
logD("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
@ -374,15 +519,15 @@ class HomeFragment :
decision.reason)
}
is PlaylistDecision.Export -> {
L.d("Exporting ${decision.playlist}")
logD("Exporting ${decision.playlist}")
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
}
is PlaylistDecision.Delete -> {
L.d("Deleting ${decision.playlist}")
logD("Deleting ${decision.playlist}")
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
}
is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} to a playlist")
logD("Adding ${decision.songs.size} to a playlist")
HomeFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -410,41 +555,157 @@ class HomeFragment :
}
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
val binding = requireBinding()
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
hideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
logD("Showing shuffle button")
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun hideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
val binding = requireBinding()
if (open) {
binding.homeNewPlaylistFab.open(true)
} else {
binding.homeNewPlaylistFab.close(true)
}
}
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
val binding = binding ?: return false
if (homeModel.speedDialOpen.value && binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
// Convert absolute coordinates to relative coordinates
val offsetX = event.x - binding.homeNewPlaylistFab.x
val offsetY = event.y - binding.homeNewPlaylistFab.y
// Create a new MotionEvent with relative coordinates
val relativeEvent =
MotionEvent.obtain(
event.downTime,
event.eventTime,
event.action,
offsetX,
offsetY,
event.metaState)
// Dispatch the relative MotionEvent to the target child view
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
// Recycle the relative MotionEvent
relativeEvent.recycle()
return handled
}
return false
}
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
L.d("Navigating to ${show.song}")
logD("Navigating to ${show.song}")
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
}
is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}")
logD("Navigating to the album of ${show.song}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController()
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
}
is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}")
logD("Navigating to ${show.album}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
}
is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}")
logD("Navigating to ${show.artist}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}")
logD("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}")
logD("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.GenreDetails -> {
L.d("Navigating to ${show.genre}")
logD("Navigating to ${show.genre}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
}
is Show.PlaylistDetails -> {
L.d("Navigating to ${show.playlist}")
logD("Navigating to ${show.playlist}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController()
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
}
@ -472,7 +733,7 @@ class HomeFragment :
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
// New selection started, show the AppBarLayout to indicate the new state.
L.d("Significant selection occurred, expanding AppBar")
logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
}
} else {
@ -480,6 +741,18 @@ class HomeFragment :
}
}
private fun applyAxisTransition(axis: Int) {
// Sanity check to avoid in-correct axis transitions
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
"Not expecting Y axis transition"
}
enterTransition = MaterialSharedAxis(axis, true)
returnTransition = MaterialSharedAxis(axis, false)
exitTransition = MaterialSharedAxis(axis, true)
reenterTransition = MaterialSharedAxis(axis, false)
}
/**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
*
@ -508,5 +781,12 @@ class HomeFragment :
private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
}
}

View file

@ -1,177 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* HomeGenerator.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
interface HomeGenerator {
fun attach()
fun release()
fun empty(): Boolean
fun songs(): List<Song>
fun albums(): List<Album>
fun artists(): List<Artist>
fun genres(): List<Genre>
fun playlists(): List<Playlist>
fun tabs(): List<MusicType>
interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs()
}
interface Factory {
fun create(invalidator: Invalidator): HomeGenerator
}
}
class HomeGeneratorFactoryImpl
@Inject
constructor(
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
) : HomeGenerator.Factory {
override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator =
HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository)
}
private class HomeGeneratorImpl(
private val invalidator: HomeGenerator.Invalidator,
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener {
override fun attach() {
homeSettings.registerListener(this)
listSettings.registerListener(this)
musicRepository.addUpdateListener(this)
}
override fun onTabsChanged() {
invalidator.invalidateTabs()
}
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
L.d("Collaborator setting changed, forwarding update")
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
}
override fun onSongSortChanged() {
super.onSongSortChanged()
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0))
}
override fun onAlbumSortChanged() {
super.onAlbumSortChanged()
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0))
}
override fun onArtistSortChanged() {
super.onArtistSortChanged()
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0))
}
override fun onGenreSortChanged() {
super.onGenreSortChanged()
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0))
}
override fun onPlaylistSortChanged() {
super.onPlaylistSortChanged()
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0))
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
invalidator.invalidateEmpty()
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
L.d("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
}
if (changes.userLibrary && library != null) {
L.d("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
}
}
override fun release() {
musicRepository.removeUpdateListener(this)
listSettings.unregisterListener(this)
homeSettings.unregisterListener(this)
}
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() =
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() =
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
override fun artists() =
musicRepository.library?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() }
} else {
sorted
}
} ?: emptyList()
override fun genres() =
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
override fun playlists() =
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
}

View file

@ -27,6 +27,4 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
interface HomeModule {
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
}

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* User configuration specific to the home UI.
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
interface Listener {
/** Called when the [homeTabs] configuration changes. */
fun onTabsChanged() {}
fun onTabsChanged()
/** Called when the [shouldHideCollaborators] configuration changes. */
fun onHideCollaboratorsChanged() {}
fun onHideCollaboratorsChanged()
}
}
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
L.d("Migrating tab setting")
logD("Migrating tab setting")
val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
L.d("Old tabs: $oldTabs")
logD("Old tabs: $oldTabs")
// The playlist tab is now parsed, but it needs to be made visible.
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
L.d("New tabs: $oldTabs")
logD("New tabs: $oldTabs")
sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
@ -90,11 +90,11 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> {
L.d("Dispatching tab setting change")
logD("Dispatching tab setting change")
listener.onTabsChanged()
}
getString(R.string.set_key_hide_collaborators) -> {
L.d("Dispatching collaborator setting change")
logD("Dispatching collaborator setting change")
listener.onHideCollaboratorsChanged()
}
}

View file

@ -27,17 +27,18 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* The ViewModel for managing the tab data and lists of the home view.
@ -48,10 +49,12 @@ import timber.log.Timber as L
class HomeViewModel
@Inject
constructor(
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val playbackSettings: PlaybackSettings,
homeGeneratorFactory: HomeGenerator.Factory
) : ViewModel(), HomeGenerator.Invalidator {
private val musicRepository: MusicRepository,
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
private val _songList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songList: StateFlow<List<Song>>
@ -120,10 +123,6 @@ constructor(
val playlistList: StateFlow<List<Playlist>>
get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions>
@ -133,13 +132,11 @@ constructor(
val playlistSort: Sort
get() = listSettings.playlistSort
private val homeGenerator = homeGeneratorFactory.create(this)
/**
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s.
*/
var currentTabTypes = homeGenerator.tabs()
var currentTabTypes = makeTabTypes()
private set
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
@ -159,57 +156,72 @@ constructor(
/** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
private val _speedDialOpen = MutableStateFlow(false)
/** A marker for whether the speed dial is open or not. */
val speedDialOpen: StateFlow<Boolean> = _speedDialOpen
private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer>
get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init {
homeGenerator.attach()
musicRepository.addUpdateListener(this)
homeSettings.registerListener(this)
}
override fun onCleared() {
super.onCleared()
homeGenerator.release()
musicRepository.removeUpdateListener(this)
homeSettings.unregisterListener(this)
}
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
logD("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songInstructions.put(UpdateInstructions.Diff)
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
_albumInstructions.put(UpdateInstructions.Diff)
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
_artistInstructions.put(UpdateInstructions.Diff)
_artistList.value =
listSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
logD("Filtering collaborator artists")
// Hide Collaborators is enabled, filter out collaborators.
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
} else {
logD("Using all artists")
deviceLibrary.artists
})
_genreInstructions.put(UpdateInstructions.Diff)
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
}
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) {
MusicType.SONGS -> {
_songInstructions.put(instructions)
_songList.value = homeGenerator.songs()
}
MusicType.ALBUMS -> {
_albumInstructions.put(instructions)
_albumList.value = homeGenerator.albums()
}
MusicType.ARTISTS -> {
_artistInstructions.put(instructions)
_artistList.value = homeGenerator.artists()
}
MusicType.GENRES -> {
_genreInstructions.put(instructions)
_genreList.value = homeGenerator.genres()
}
MusicType.PLAYLISTS -> {
_playlistInstructions.put(instructions)
_playlistList.value = homeGenerator.playlists()
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
logD("Refreshing playlists")
_playlistInstructions.put(UpdateInstructions.Diff)
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
}
}
override fun invalidateTabs() {
currentTabTypes = homeGenerator.tabs()
override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabTypes = makeTabTypes()
logD("Updating tabs: ${currentTabType.value}")
_shouldRecreate.put(Unit)
}
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
logD("Collaborator setting changed, forwarding update")
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
}
/**
* Apply a new [Sort] to [songList].
*
@ -217,6 +229,8 @@ constructor(
*/
fun applySongSort(sort: Sort) {
listSettings.songSort = sort
_songInstructions.put(UpdateInstructions.Replace(0))
_songList.value = listSettings.songSort.songs(_songList.value)
}
/**
@ -226,6 +240,8 @@ constructor(
*/
fun applyAlbumSort(sort: Sort) {
listSettings.albumSort = sort
_albumInstructions.put(UpdateInstructions.Replace(0))
_albumList.value = listSettings.albumSort.albums(_albumList.value)
}
/**
@ -235,6 +251,8 @@ constructor(
*/
fun applyArtistSort(sort: Sort) {
listSettings.artistSort = sort
_artistInstructions.put(UpdateInstructions.Replace(0))
_artistList.value = listSettings.artistSort.artists(_artistList.value)
}
/**
@ -244,6 +262,8 @@ constructor(
*/
fun applyGenreSort(sort: Sort) {
listSettings.genreSort = sort
_genreInstructions.put(UpdateInstructions.Replace(0))
_genreList.value = listSettings.genreSort.genres(_genreList.value)
}
/**
@ -253,6 +273,8 @@ constructor(
*/
fun applyPlaylistSort(sort: Sort) {
listSettings.playlistSort = sort
_playlistInstructions.put(UpdateInstructions.Replace(0))
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
}
/**
@ -261,7 +283,7 @@ constructor(
* @param pagerPos The new position of the ViewPager2 instance.
*/
fun synchronizeTabPosition(pagerPos: Int) {
L.d("Updating current tab to ${currentTabTypes[pagerPos]}")
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
_currentTabType.value = currentTabTypes[pagerPos]
}
@ -271,12 +293,18 @@ constructor(
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun setFastScrolling(isFastScrolling: Boolean) {
L.d("Updating fast scrolling state: $isFastScrolling")
logD("Updating fast scrolling state: $isFastScrolling")
_isFastScrolling.value = isFastScrolling
}
fun startChooseMusicLocations() {
_chooseMusicLocations.put(Unit)
/**
* Update whether the speed dial is open or not.
*
* @param speedDialOpen true if the speed dial is open, false otherwise.
*/
fun setSpeedDialOpen(speedDialOpen: Boolean) {
logD("Updating speed dial state: $speedDialOpen")
_speedDialOpen.value = speedDialOpen
}
fun showSettings() {
@ -286,6 +314,15 @@ constructor(
fun showAbout() {
_showOuter.put(Outer.About)
}
/**
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
*
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
* the same way as the configuration.
*/
private fun makeTabTypes() =
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
}
sealed interface Outer {

View file

@ -39,6 +39,7 @@ import androidx.core.os.BundleCompat
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.shape.MaterialShapeDrawable
import com.leinardi.android.speeddial.FabWithLabelView
import com.leinardi.android.speeddial.SpeedDialActionItem
@ -46,7 +47,6 @@ import com.leinardi.android.speeddial.SpeedDialView
import kotlin.math.roundToInt
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AnimConfig
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
@ -78,8 +78,6 @@ class ThemedSpeedDialView : SpeedDialView {
@AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
init {
// Work around ripple bug on Android 12 when useCompatPadding = true.
// @see https://github.com/material-components/material-components-android/issues/2617
@ -109,7 +107,7 @@ class ThemedSpeedDialView : SpeedDialView {
val mainFabDrawable =
RotateDrawable().apply {
drawable = mainFab.drawable
toDegrees = 45f + 90f
toDegrees = mainFabAnimationRotateAngle
}
mainFabAnimationRotateAngle = 0f
setMainFabClosedDrawable(mainFabDrawable)
@ -118,13 +116,6 @@ class ThemedSpeedDialView : SpeedDialView {
override fun onMainActionSelected(): Boolean = false
override fun onToggleChanged(isOpen: Boolean) {
mainFab.backgroundTintList =
ColorStateList.valueOf(
if (isOpen) mainFabClosedBackgroundColor
else mainFabOpenedBackgroundColor)
mainFab.imageTintList =
ColorStateList.valueOf(
if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor)
mainFabAnimator?.cancel()
mainFabAnimator =
createMainFabAnimator(isOpen).apply {
@ -141,44 +132,22 @@ class ThemedSpeedDialView : SpeedDialView {
})
}
private fun createMainFabAnimator(isOpen: Boolean): Animator {
val totalDuration = stationaryConfig.duration
val partialDuration = totalDuration / 2 // This is half of the total duration
val delay = totalDuration / 4 // This is one fourth of the total duration
val backgroundTintAnimator =
ObjectAnimator.ofArgb(
private fun createMainFabAnimator(isOpen: Boolean): Animator =
AnimatorSet().apply {
playTogether(
ObjectAnimator.ofArgb(
mainFab,
VIEW_PROPERTY_BACKGROUND_TINT,
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor)
.apply {
startDelay = delay
duration = partialDuration
}
val imageTintAnimator =
ObjectAnimator.ofArgb(
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
ObjectAnimator.ofArgb(
mainFab,
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor)
.apply {
startDelay = delay
duration = partialDuration
}
val levelAnimator =
ObjectAnimator.ofInt(
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0)
.apply { duration = totalDuration }
val animatorSet =
AnimatorSet().apply {
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
interpolator = stationaryConfig.interpolator
}
animatorSet.start()
return animatorSet
}
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
ObjectAnimator.ofInt(
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
duration = 200
interpolator = FastOutSlowInInterpolator()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
@ -190,8 +159,6 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor)
}
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
}
private fun Int.withModulatedAlpha(
@ -232,24 +199,13 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply {
updateLayoutParams<MarginLayoutParams> {
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
} else {
setMargins(0, 0, rightMargin, 0)
}
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
setMargins(horizontalMargin, 0, horizontalMargin, 0)
}
useCompatPadding = false
}
labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background =
@ -306,7 +262,7 @@ class ThemedSpeedDialView : SpeedDialView {
private val DRAWABLE_PROPERTY_LEVEL =
object : Property<Drawable, Int>(Int::class.java, "level") {
override fun get(drawable: Drawable): Int = drawable.level
override fun get(drawable: Drawable): Int? = drawable.level
override fun set(drawable: Drawable, value: Int?) {
drawable.level = value!!

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 2022 Auxio Project
* FastScrollPopupView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import androidx.core.widget.TextViewCompat
import com.google.android.material.R as MR
import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.isRtl
/**
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
*
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/
class FastScrollPopupView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
MaterialTextView(context, attrs, defStyleRes) {
init {
minimumWidth = context.getDimenPixels(R.dimen.fast_scroll_popup_min_width)
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
alpha = 0f
elevation = context.getDimenPixels(R.dimen.elevation_normal).toFloat()
background = FastScrollPopupDrawable(context)
}
private class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint =
Paint().apply {
isAntiAlias = true
color =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
.defaultColor
style = Paint.Style.FILL
}
private val path = Path()
private val matrix = Matrix()
private val paddingStart = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_start)
private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath()
return true
}
@Suppress("DEPRECATION")
override fun getOutline(outline: Outline) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
// Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating
// point errors caused by calculations involving sqrt(2) or OEM differences,
// so in this case we just omit the shadow instead of crashing.
super.getOutline(outline)
}
}
}
override fun getPadding(padding: Rect): Boolean {
if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0
} else {
padding[paddingStart, 0, paddingEnd] = 0
}
return true
}
override fun isAutoMirrored(): Boolean = true
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun updatePath() {
val r = bounds.height().toFloat() / 2
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
path.apply {
reset()
// Draw the left pill shape
val o1X = w - SQRT2 * r
arcToSafe(r, r, r, 90f, 180f)
arcToSafe(o1X, r, r, -90f, 45f)
// Draw the right arrow shape
val point = r / 5
val o2X = w - SQRT2 * point
arcToSafe(o2X, r, point, -45f, 90f)
arcToSafe(o1X, r, r, 45f, 45f)
close()
}
matrix.apply {
reset()
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
}
path.transform(matrix)
}
private fun Path.arcToSafe(
centerX: Float,
centerY: Float,
radius: Float,
startAngle: Float,
sweepAngle: Float
) {
arcTo(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
}
}
private companion object {
// Pre-calculate sqrt(2)
const val SQRT2 = 1.4142135f
}
}

View file

@ -16,18 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.list.recycler
package org.oxycblt.auxio.home.fastscroll
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
@ -35,22 +30,16 @@ import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible
import androidx.core.view.updatePaddingRelative
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -77,73 +66,52 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Variable names are no longer prefixed with m
* - Added drag listener
* - Added documentation
* - Completely new design
* - New scroll position backend
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*
* TODO: Add vibration when popup changes
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*/
class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private var thumbAnimator: Animator? = null
@SuppressLint("InflateParams")
private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
thumbSlider.jumpOut(this)
View(context).apply {
alpha = 0f
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
}
private val thumbWidth = thumbView.background.intrinsicWidth
private val thumbHeight = thumbView.background.intrinsicHeight
private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0
private var showingThumb = false
private val hideThumbRunnable = Runnable {
if (!dragging) {
hideThumb()
hideScrollbar()
}
}
// Popup
private val popupView =
MaterialTextView(context).apply {
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
setTextColor(
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
elevation =
context
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
.toFloat()
background = context.getDrawableCompat(R.drawable.ui_popup)
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
updatePaddingRelative(start = paddingStart, end = paddingEnd)
FastScrollPopupView(context).apply {
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
}
}
private val popupSlider =
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
jumpOut(popupView)
}
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val minTouchTargetSize =
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
@ -152,24 +120,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f
private var dragStartThumbOffset = 0
private var fastScrollingPossible = true
var fastScrollingEnabled = true
set(value) {
if (field == value) {
return
}
field = value
if (!value) {
removeCallbacks(hideThumbRunnable)
hideThumb()
hidePopup()
}
listener?.onFastScrollingChanged(field)
}
private var dragging = false
set(value) {
if (field == value) {
@ -189,13 +139,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showScrollbar()
showPopup()
} else {
hidePopup()
postAutoHideScrollbar()
hidePopup()
}
listener?.onFastScrollingChanged(field)
}
private val tRect = Rect()
var popupProvider: PopupProvider? = null
var listener: Listener? = null
@ -230,22 +182,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() {
updateThumbState()
updateScrollbarState()
thumbView.layoutDirection = layoutDirection
thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset
popupView.layoutDirection = layoutDirection
val thumbLeft =
if (isRtl) {
thumbPadding.left
} else {
width - thumbPadding.right - thumbWidth
}
val thumbTop = thumbPadding.top + thumbOffset
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
@ -262,9 +214,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = false
popupView.isInvisible = true
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
@ -290,9 +243,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
}
val popupWidth = popupView.measuredWidth
@ -305,7 +255,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.height / 2
val thumbAnchorY = thumbView.paddingTop
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
@ -319,7 +269,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
updateThumbState()
updateScrollbarState()
// Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) {
@ -337,27 +287,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets
}
private fun updateThumbState() {
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || isEmpty()) {
fastScrollingPossible = false
hideThumb()
hidePopup()
private fun updateScrollbarState() {
if (scrollRange <= height || childCount == 0) {
return
}
val extentY = computeVerticalScrollExtent()
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY)
thumbOffset = (thumbOffsetRange * fraction).toInt()
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
val child = getChildAt(0)
val firstAdapterPos =
when (val mgr = layoutManager) {
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
is LinearLayoutManager -> mgr.getPosition(child)
else -> 0
}
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
}
private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x
val eventY = event.y
@ -371,12 +324,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset
} else if (eventX > thumbView.right - thumbWidth / 4) {
} else {
dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
} else {
return false
}
dragging = true
@ -413,19 +364,44 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun scrollToThumbOffset(thumbOffset: Int) {
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
if (newOffsetY == 0f) {
// Hacky workaround to drift in vertical scroll offset where we just snap
// to the top if the thumb offset hit zero.
scrollToPosition(0)
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val scrollOffset =
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
paddingTop
scrollTo(scrollOffset)
}
private fun scrollTo(offset: Int) {
if (childCount == 0) {
return
}
val dy = newOffsetY - previousOffsetY
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
stopScroll()
val trueOffset = offset - paddingTop
val itemHeight = itemHeight
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
val firstItemTop = firstItemPosition * itemHeight - trueOffset
scrollToPositionWithOffset(firstItemPosition, firstItemTop)
}
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
var targetPosition = position
val trueOffset = offset - paddingTop
when (val mgr = layoutManager) {
is GridLayoutManager -> {
targetPosition *= mgr.spanCount
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
is LinearLayoutManager -> {
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
}
}
// --- SCROLLBAR APPEARANCE ---
@ -436,39 +412,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) {
return
}
showingThumb = true
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
animateViewIn(thumbView)
}
private fun hideThumb() {
private fun hideScrollbar() {
if (!showingThumb) {
return
}
showingThumb = false
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
animateViewOut(thumbView)
}
private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) {
return
}
showingPopup = true
popupAnimator?.cancel()
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
animateViewIn(popupView)
}
private fun hidePopup() {
@ -477,17 +444,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingPopup = false
popupAnimator?.cancel()
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
animateViewOut(popupView)
}
private fun doPopupVibration() {
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {
HapticFeedbackConstants.TEXT_HANDLE_MOVE
} else {
HapticFeedbackConstants.KEYBOARD_TAP
})
private fun animateViewIn(view: View) {
view
.animate()
.alpha(1f)
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
.start()
}
private fun animateViewOut(view: View) {
view
.animate()
.alpha(0f)
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.start()
}
// --- LAYOUT STATE ---
@ -497,6 +470,45 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
}
private val scrollRange: Int
get() {
val itemCount = itemCount
if (itemCount == 0) {
return 0
}
val itemHeight = itemHeight
return if (itemHeight != 0) {
paddingTop + itemCount * itemHeight + paddingBottom
} else {
0
}
}
private val scrollOffsetRange: Int
get() = scrollRange - height
private val itemHeight: Int
get() {
if (childCount == 0) {
return 0
}
val itemView = getChildAt(0)
getDecoratedBoundsWithMargins(itemView, tRect)
return tRect.height()
}
private val itemCount: Int
get() =
when (val mgr = layoutManager) {
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
is LinearLayoutManager -> mgr.itemCount
else -> 0
}
/** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider {
/**
@ -520,6 +532,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private companion object {
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
}
}

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
@ -31,23 +29,22 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Album]s.
@ -82,16 +79,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_album_48)
contentDescription = getString(R.string.lbl_albums)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,10 +99,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> album.name.thumb()
is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].name.thumb()
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -127,7 +115,7 @@ class AlbumListFragment :
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.addedMs
val dateAddedMillis = album.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -159,14 +147,6 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,31 +21,28 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Artist]s.
@ -77,16 +74,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_artist_48)
contentDescription = getString(R.string.lbl_artists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -106,7 +94,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb()
is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -135,14 +123,6 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,30 +21,27 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Genre]s.
@ -76,16 +73,7 @@ class GenreListFragment :
listener = this@GenreListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_genre_48)
contentDescription = getString(R.string.lbl_genres)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +93,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb()
is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -134,14 +122,6 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ListUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.list
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
tokens.firstOrNull()?.let {
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
}
is Name.Unknown -> "?"
}

View file

@ -21,29 +21,26 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Playlist]s.
@ -74,18 +71,7 @@ class PlaylistListFragment :
listener = this@PlaylistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_playlist_48)
contentDescription = getString(R.string.lbl_playlists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
collectImmediately(homeModel.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +91,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb()
is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -134,26 +120,6 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
}
private fun updateNoMusicIndicator(
empty: Boolean,
playlists: List<Playlist>,
indexingState: IndexingState?
) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
if (!empty && playlists.isEmpty()) {
binding.homeNoMusicAction.isVisible = true
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
} else {
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
}
}
private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -22,30 +22,27 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Song]s.
@ -62,7 +59,6 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb)
@ -80,16 +76,7 @@ class SongListFragment :
listener = this@SongListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_song_48)
contentDescription = getString(R.string.lbl_songs)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,23 +98,23 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.name.thumb()
is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb()
is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext())
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.addedMs
val dateAddedMillis = song.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -159,14 +146,6 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2022 Auxio Project
* AdaptiveTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import android.content.Context
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicType
/**
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
* depending on the screen configuration.
*
* @param context [Context] required to obtain window information
* @param tabs Current tab configuration from settings
* @author Alexander Capehart (OxygenCobalt)
*/
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
TabLayoutMediator.TabConfigurationStrategy {
private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val icon: Int
val string: Int
when (tabs[position]) {
MusicType.SONGS -> {
icon = R.drawable.ic_song_24
string = R.string.lbl_songs
}
MusicType.ALBUMS -> {
icon = R.drawable.ic_album_24
string = R.string.lbl_albums
}
MusicType.ARTISTS -> {
icon = R.drawable.ic_artist_24
string = R.string.lbl_artists
}
MusicType.GENRES -> {
icon = R.drawable.ic_genre_24
string = R.string.lbl_genres
}
MusicType.PLAYLISTS -> {
icon = R.drawable.ic_playlist_24
string = R.string.lbl_playlists
}
}
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(string)
// On large screens, display an icon and text.
width < 600 -> tab.setText(string)
// On medium-size screens, display text.
else -> tab.setIcon(icon).setText(string)
}
}
}

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* NamedTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.oxycblt.auxio.music.MusicType
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(homeTabs[position].nameRes)
}
}

View file

@ -19,7 +19,8 @@
package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.music.MusicType
import timber.log.Timber as L
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/**
* A representation of a library tab suitable for configuration.
@ -85,7 +86,7 @@ sealed class Tab(open val type: MusicType) {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) {
L.w(
logW(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
}
@ -132,13 +133,13 @@ sealed class Tab(open val type: MusicType) {
// Make sure there are no duplicate tabs
val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) {
L.w(
logW(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
}
// For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
L.e("Sequence size was ${distinct.size}, which is invalid")
logE("Sequence size was ${distinct.size}, which is invalid")
return null
}

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.util.inflater
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
@ -55,7 +55,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param newTabs The new array of tabs to show.
*/
fun submitTabs(newTabs: Array<Tab>) {
L.d("Force-updating tab information")
logD("Force-updating tab information")
tabs = newTabs
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
}
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param tab The new tab.
*/
fun setTab(at: Int, tab: Tab) {
L.d("Updating tab [at: $at, tab: $tab]")
logD("Updating tab [at: $at, tab: $tab]")
tabs[at] = tab
// Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
@ -80,7 +80,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param b The position of the second tab to swap.
*/
fun swapTabs(a: Int, b: Int) {
L.d("Swapping tabs [a: $a, b: $b]")
logD("Swapping tabs [a: $a, b: $b]")
val tmp = tabs[b]
tabs[b] = tabs[a]
tabs[a] = tmp

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
@ -52,7 +52,7 @@ class TabCustomizeDialog :
builder
.setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
L.d("Committing tab changes")
logD("Committing tab changes")
homeSettings.homeTabs = tabAdapter.tabs
}
.setNegativeButton(R.string.lbl_cancel, null)
@ -99,7 +99,7 @@ class TabCustomizeDialog :
is Tab.Visible -> Tab.Invisible(old.type)
is Tab.Invisible -> Tab.Visible(old.type)
}
L.d("Flipping tab visibility [from: $old to: $new]")
logD("Flipping tab visibility [from: $old to: $new]")
tabAdapter.setTab(index, new)
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.

View file

@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Bitmap
import coil3.ImageLoader
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.toBitmap
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Song
/**
* A utility to provide bitmaps in a race-less manner.
@ -94,7 +94,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(song.cover)
.data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL))
.target(

View file

@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
* @author Alexander Capehart (OxygenCobalt)
*/
enum class CoverMode {
/** Do not load album covers ("Off"). */
OFF,
SAVE_SPACE,
BALANCED,
HIGH_QUALITY,
AS_IS;
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
MEDIA_STORE,
/** Load high-quality covers directly from music files ("Quality"). */
QUALITY;
/**
* The integer representation of this instance.
@ -41,10 +42,8 @@ enum class CoverMode {
get() =
when (this) {
OFF -> IntegerTable.COVER_MODE_OFF
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
BALANCED -> IntegerTable.COVER_MODE_BALANCED
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
AS_IS -> IntegerTable.COVER_MODE_AS_IS
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
QUALITY -> IntegerTable.COVER_MODE_QUALITY
}
companion object {
@ -58,10 +57,8 @@ enum class CoverMode {
fun fromIntCode(intCode: Int) =
when (intCode) {
IntegerTable.COVER_MODE_OFF -> OFF
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
IntegerTable.COVER_MODE_BALANCED -> BALANCED
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
IntegerTable.COVER_MODE_AS_IS -> AS_IS
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
IntegerTable.COVER_MODE_QUALITY -> QUALITY
else -> null
}
}

View file

@ -1,86 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* CoverProvider.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.musikr.covers.CoverResult
class CoverProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r" || uriMatcher.match(uri) != 1) {
return null
}
val id = uri.lastPathSegment ?: return null
return runBlocking {
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
is CoverResult.Hit -> result.cover.fd()
else -> null
}
}
}
override fun getType(uri: Uri): String {
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
return "image/*"
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = throw UnsupportedOperationException()
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
companion object {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
private const val IMAGES_PATH = "covers"
private val uriMatcher =
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
val CONTENT_URI: Uri =
Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(IMAGES_PATH)
.build()
}
}

View file

@ -18,7 +18,7 @@
package org.oxycblt.auxio.image
import android.animation.Animator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
@ -33,39 +33,36 @@ import android.view.Gravity
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.core.content.res.getIntOrThrow
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.target
import coil3.request.transformations
import coil3.util.CoilUtils
import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
import org.oxycblt.auxio.image.coil.SquareCropTransformation
import org.oxycblt.auxio.ui.MaterialFader
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.auxio.util.getInteger
/**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -95,41 +92,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val playbackIndicator: PlaybackIndicator?
private val selectionBadge: ImageView?
private val iconSize: Int?
private val fader = MaterialFader.quickLopsided(context)
private var fadeAnimator: Animator? = null
private val sizing: Int
@DimenRes private val iconSizeRes: Int?
@DimenRes private var cornerRadiusRes: Int?
private var fadeAnimator: ValueAnimator? = null
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
private val shapeAppearance: ShapeAppearanceModel
init {
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0)
shapeAppearance =
if (uiSettings.roundMode) {
if (shapeAppearanceRes != 0) {
ShapeAppearanceModel.builder(context, shapeAppearanceRes, -1).build()
} else {
ShapeAppearanceModel.builder(
context,
com.google.android.material.R.style
.ShapeAppearance_Material3_Corner_Medium,
-1)
.build()
}
} else {
ShapeAppearanceModel.builder().build()
}
iconSize =
styledAttrs.getDimensionPixelSize(R.styleable.CoverView_iconSize, -1).takeIf {
it != -1
}
sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
iconSizeRes = SIZING_ICON_SIZE[sizing]
cornerRadiusRes = getCornerRadiusRes()
val playbackIndicatorEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
@ -173,7 +153,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design.
if (isEmpty()) {
if (childCount == 0) {
addView(image)
}
@ -203,7 +183,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
// behavior with a matrix.
val playbackIndicator = (playbackIndicator ?: return).view
val iconSize = iconSize ?: (measuredWidth / 2)
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
playbackIndicator.apply {
imageMatrix =
indicatorMatrix.apply {
@ -267,8 +247,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
private fun applyBackgroundsToChildren() {
private fun getCornerRadiusRes() =
if (!isInEditMode && uiSettings.roundMode) {
SIZING_CORNER_RADII[sizing]
} else {
null
}
private fun applyBackgroundsToChildren() {
// Add backgrounds to each child for visual consistency
for (child in children) {
child.apply {
@ -278,7 +264,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
shapeAppearanceModel = shapeAppearance
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
}
}
}
@ -304,10 +290,43 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
fadeAnimator?.cancel()
// Set up a target transition for the selection indicator.
val targetAlpha: Float
val targetDuration: Long
if (isActivated) {
// View is "activated" (i.e marked as selected), so show the selection indicator.
targetAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
// View is not "activated", hide the selection indicator.
targetAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
if (selectionBadge.alpha == targetAlpha) {
// Nothing to do.
return
}
if (!isLaidOut) {
// Not laid out, initialize it without animation before drawing.
selectionBadge.alpha = targetAlpha
return
}
if (fadeAnimator != null) {
// Cancel any previous animation.
fadeAnimator?.cancel()
fadeAnimator = null
}
fadeAnimator =
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge))
.also { it.start() }
ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
duration = targetDuration
addUpdateListener { selectionBadge.alpha = it.animatedValue as Float }
start()
}
}
/**
@ -317,7 +336,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(song: Song) =
bindImpl(
song.cover,
listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24)
@ -328,7 +347,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(album: Album) =
bindImpl(
album.covers,
album.cover.all,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@ -339,7 +358,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(artist: Artist) =
bindImpl(
artist.covers,
artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@ -350,7 +369,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) =
bindImpl(
genre.covers,
genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@ -361,7 +380,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(playlist: Playlist) =
bindImpl(
playlist.covers,
playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@ -373,21 +392,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
bindImpl(Cover.order(songs), desc, errorRes)
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(cover)
.error(
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.target(image)
val cornersTransformation =
RoundedRectTransformation(
shapeAppearance.topLeftCornerSize.getCornerSize(
RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())))
RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)
if (imageSettings.forceSquareCovers) {
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
} else {
@ -407,7 +422,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private class StyledDrawable(
context: Context,
private val inner: Drawable,
@Px val iconSize: Int?
@DimenRes iconSizeRes: Int?
) : Drawable() {
init {
// Re-tint the drawable to use the analogous "on surface" color for
@ -415,10 +430,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}
private val dimen = iconSizeRes?.let(context::getDimenPixels)
override fun draw(canvas: Canvas) {
// Resize the drawable such that it's always 1/4 the size of the image and
// centered in the middle of the canvas.
val adj = iconSize?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
inner.draw(canvas)
}
@ -435,4 +452,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
companion object {
val SIZING_CORNER_RADII =
arrayOf(
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
}
}

View file

@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* User configuration specific to image loading.
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() =
CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.BALANCED
?: CoverMode.MEDIA_STORE
override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
@ -58,14 +58,14 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
// Show album covers and Ignore MediaStore covers were unified in 3.0.0
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
L.d("Migrating cover settings")
logD("Migrating cover settings")
val mode =
when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.BALANCED
else -> CoverMode.BALANCED
CoverMode.MEDIA_STORE
else -> CoverMode.QUALITY
}
sharedPreferences.edit {
@ -74,30 +74,12 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS)
}
}
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
L.d("Migrating cover mode setting")
var mode =
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
?: CoverMode.BALANCED
if (mode == CoverMode.HIGH_QUALITY) {
// High quality now has space characteristics that could be
// undesirable, clamp to balanced.
mode = CoverMode.BALANCED
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
remove(OLD_KEY_COVER_MODE)
}
}
}
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
if (key == getString(R.string.set_key_cover_mode) ||
key == getString(R.string.set_key_square_covers)) {
L.d("Dispatching image setting change")
logD("Dispatching image setting change")
listener.onImageSettingsChanged()
}
}
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
}
}

View file

@ -1,140 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverCollectionFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Canvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.CoverCollection
class CoverCollectionFetcher
private constructor(
private val context: Context,
private val covers: CoverCollection,
private val size: Size,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a
// mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return ImageFetchResult(
image = mosaicBitmap.toDrawable(context.resources).asImage(),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
CoverCollectionFetcher(options.context, data, options.size)
}
}

View file

@ -1,47 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.Cover
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
override suspend fun fetch(): FetchResult? {
val stream = cover.open() ?: return null
return SourceFetchResult(
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data)
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* Keyers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
override fun key(data: CoverCollection, options: Options) =
"multi:${data.hashCode()}&${options.size}"
}

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* NullCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
override suspend fun cleanup(excluding: Collection<Cover>) {
storage.ls(setOf()).map { storage.rm(it) }
}
}
data object NullCover : Cover {
override val id = "null"
override suspend fun open() = null
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* RevisionedTranscoding.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import java.util.UUID
import org.oxycblt.musikr.covers.stored.Transcoding
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
override val tag = "_$revision${inner.tag}"
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* SettingCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import android.content.Context
import android.graphics.Bitmap
import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.chained.ChainedCovers
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.covers.stored.Compress
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
import org.oxycblt.musikr.covers.stored.NoTranscoding
import org.oxycblt.musikr.covers.stored.StoredCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
suspend fun immutable(context: Context): Covers<FDCover> =
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
}
}
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
SettingCovers {
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
val coverStorage = CoverStorage.at(context.coversDir())
val transcoding =
when (imageSettings.coverMode) {
CoverMode.OFF -> return NullCovers(coverStorage)
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
CoverMode.AS_IS -> NoTranscoding
}
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
val storedCovers =
MutableStoredCovers(
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
val fsCovers = MutableFSCovers(context)
return MutableChainedCovers(storedCovers, fsCovers)
}
}
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import coil.ImageLoader
import coil.fetch.Fetcher
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
override fun key(data: Collection<Cover>, options: Options) =
"${data.map { it.key }.hashCode()}"
}
class CoverFetcher
private constructor(
private val covers: Collection<Cover>,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor)
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 Auxio Project
* Cover.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
sealed interface Cover {
val key: String
val mediaStoreCoverUri: Uri
/**
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
*/
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
Cover {
override val mediaStoreCoverUri = songCoverUri
override val key = perceptualHash
}
/**
* We couldn't find any embedded cover art ourselves, but the android system might have some
* through a cover.jpg file or something similar.
*/
data class External(val albumCoverUri: Uri) : Cover {
override val mediaStoreCoverUri = albumCoverUri
override val key = albumCoverUri.toString()
}
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.key }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -0,0 +1,249 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverExtractor.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logE
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceResult(
source = ImageSource(first.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (cover) {
is Cover.Embedded ->
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
is Cover.External -> {
extractMediaStoreCover(cover)
}
}
} catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractAospMetadataCover(cover)
?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import coil3.decode.DataSource
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.transition.CrossfadeDrawable
import coil3.transition.CrossfadeTransition
import coil3.transition.Transition
import coil3.transition.TransitionTarget
import coil.decode.DataSource
import coil.drawable.CrossfadeDrawable
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.transition.CrossfadeTransition
import coil.transition.Transition
import coil.transition.TransitionTarget
/**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* CoilModule.kt is part of Auxio.
* ExtractorModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.content.Context
import coil3.ImageLoader
import coil3.request.CachePolicy
import coil3.request.transitionFactory
import coil.ImageLoader
import coil.request.CachePolicy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,22 +30,19 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class CoilModule {
class ExtractorModule {
@Singleton
@Provides
fun imageLoader(
@ApplicationContext context: Context,
coverKeyer: CoverKeyer,
coverFactory: CoverFetcher.Factory,
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
keyer: CoverKeyer,
factory: CoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
add(coverKeyer)
add(coverFactory)
add(coverCollectionKeyer)
add(coverCollectionFactory)
// Add fetchers for Music components to make them usable with ImageRequest
add(keyer)
add(factory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader
import androidx.annotation.Px
import androidx.core.graphics.applyCanvas
import coil3.decode.DecodeUtils
import coil3.size.Scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
import coil.decode.DecodeUtils
import coil.size.Scale
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import kotlin.math.roundToInt
/**
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
* without cropping them.
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
* images without cropping them.
*
* @author Coil Team, Alexander Capehart (OxygenCobalt)
*/
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f
) : Transformation() {
) : Transformation {
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
@ -65,11 +65,7 @@ class RoundedRectTransformation(
val (outputWidth, outputHeight) = calculateOutputSize(input, size)
val output =
createBitmap(
outputWidth,
outputHeight,
requireNotNull(input.config) { "unsupported bitmap format" })
val output = createBitmap(outputWidth, outputHeight, input.config)
output.applyCanvas {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
@ -111,10 +107,7 @@ class RoundedRectTransformation(
}
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
if (size == Size.ORIGINAL) {
// This path only runs w/the widget code, which already normalizes widget sizes
return input.width to input.height
}
// MODIFICATION: Remove short-circuiting for original size and input size
val multiplier =
DecodeUtils.computeSizeMultiplier(
srcWidth = input.width,

View file

@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import androidx.core.graphics.scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import kotlin.math.min
/**
@ -31,7 +30,7 @@ import kotlin.math.min
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SquareCropTransformation : Transformation() {
class SquareCropTransformation : Transformation {
override val cacheKey: String
get() = "SquareCropTransformation"
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it.
return dst.scale(desiredWidth, desiredHeight)
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
}
return dst
}

View file

@ -22,16 +22,14 @@ import androidx.annotation.StringRes
// TODO: Consider breaking this up into sealed classes for individual adapters
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
typealias Item = Any
interface Header
interface Item
/**
* A "header" used for delimiting groups of data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface PlainHeader : Header {
interface Header : Item {
/** The string resource used for the header's title. */
val titleRes: Int
}
@ -42,16 +40,12 @@ interface PlainHeader : Header {
* @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt)
*/
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
interface Divider<T> {
val anchor: T?
}
data class BasicHeader(@StringRes override val titleRes: Int) : Header
/**
* A divider decoration used to delimit groups of data.
*
* @param anchor The [PlainHeader] this divider should be next to in a list. Used as a way to
* preserve divider continuity during list updates.
* @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
* divider continuity during list updates.
*/
data class PlainDivider(override val anchor: PlainHeader?) : Divider<PlainHeader>
data class Divider(val anchor: Header?) : Item

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.musikr.Music
import org.oxycblt.auxio.music.Music
/**
* A Fragment containing a selectable list.

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.settings.Settings
interface ListSettings : Settings<ListSettings.Listener> {
interface ListSettings : Settings<Unit> {
/** The [Sort] mode used in Song lists. */
var songSort: Sort
/** The [Sort] mode used in Album lists. */
@ -43,28 +43,10 @@ interface ListSettings : Settings<ListSettings.Listener> {
var artistSongSort: Sort
/** The [Sort] mode used in a Genre's Song list. */
var genreSongSort: Sort
interface Listener {
fun onSongSortChanged() {}
fun onAlbumSortChanged() {}
fun onAlbumSongSortChanged() {}
fun onArtistSortChanged() {}
fun onArtistSongSortChanged() {}
fun onGenreSortChanged() {}
fun onGenreSongSortChanged() {}
fun onPlaylistSortChanged() {}
}
}
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
Settings.Impl<ListSettings.Listener>(context), ListSettings {
Settings.Impl<Unit>(context), ListSettings {
override var songSort: Sort
get() =
Sort.fromIntCode(
@ -163,17 +145,4 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
apply()
}
}
override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
when (key) {
getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
}
}
}

View file

@ -25,18 +25,19 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* A [ViewModel] that orchestrates menu dialogs and selection state.
@ -64,17 +65,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val library = musicRepository.library ?: return
val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list.
_selected.value =
_selected.value.mapNotNull {
when (it) {
is Song -> library.findSong(it.uid)
is Album -> library.findAlbum(it.uid)
is Artist -> library.findArtist(it.uid)
is Genre -> library.findGenre(it.uid)
is Playlist -> library.findPlaylist(it.uid)
is Song -> deviceLibrary.findSong(it.uid)
is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> userLibrary.findPlaylist(it.uid)
}
}
}
@ -92,16 +94,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
*/
fun select(music: Music) {
if (music is MusicParent && music.songs.isEmpty()) {
L.d("Cannot select empty parent, ignoring operation")
logD("Cannot select empty parent, ignoring operation")
return
}
val selected = _selected.value.toMutableList()
if (!selected.remove(music)) {
L.d("Adding $music to selection")
logD("Adding $music to selection")
selected.add(music)
} else {
L.d("Removed $music from selection")
logD("Removed $music from selection")
}
_selected.value = selected
@ -129,7 +131,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @return A list of [Song]s collated from each item selected.
*/
fun takeSelection(): List<Song> {
L.d("Taking selection")
logD("Taking selection")
return peekSelection().also { _selected.value = listOf() }
}
@ -139,7 +141,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @return true if the prior selection was non-empty, false otherwise.
*/
fun dropSelection(): Boolean {
L.d("Dropping selection [empty=${_selected.value.isEmpty()}]")
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
}
@ -153,7 +155,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* should do.
*/
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
L.d("Opening menu for $song")
logD("Opening menu for $song")
openImpl(Menu.ForSong(menuRes, song, playWith))
}
@ -165,7 +167,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param album The [Album] to show.
*/
fun openMenu(@MenuRes menuRes: Int, album: Album) {
L.d("Opening menu for $album")
logD("Opening menu for $album")
openImpl(Menu.ForAlbum(menuRes, album))
}
@ -177,7 +179,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param artist The [Artist] to show.
*/
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
L.d("Opening menu for $artist")
logD("Opening menu for $artist")
openImpl(Menu.ForArtist(menuRes, artist))
}
@ -189,7 +191,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param genre The [Genre] to show.
*/
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
L.d("Opening menu for $genre")
logD("Opening menu for $genre")
openImpl(Menu.ForGenre(menuRes, genre))
}
@ -201,7 +203,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param playlist The [Playlist] to show.
*/
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
L.d("Opening menu for $playlist")
logD("Opening menu for $playlist")
openImpl(Menu.ForPlaylist(menuRes, playlist))
}
@ -213,14 +215,14 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param songs The [Song] selection to show.
*/
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
L.d("Opening menu for ${songs.size} songs")
logD("Opening menu for ${songs.size} songs")
openImpl(Menu.ForSelection(menuRes, songs))
}
private fun openImpl(menu: Menu) {
val existing = _menu.flow.value
if (existing != null) {
L.w("Already opening $existing, ignoring $menu")
logW("Already opening $existing, ignoring $menu")
return
}
_menu.put(menu)

View file

@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A variant of ListDiffer with more flexible updates.
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
instructions: UpdateInstructions?,
callback: (() -> Unit)? = null
) {
L.d("Updating list to ${newList.size} items with $instructions")
logD("Updating list to ${newList.size} items with $instructions")
differ.update(newList, instructions, callback)
}
}
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
) {
// fast simple remove all
if (newList.isEmpty()) {
L.d("Short-circuiting diff to remove all")
logD("Short-circuiting diff to remove all")
val countRemoved = oldList.size
currentList = emptyList()
// notify last, after list is updated
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
// fast simple first insert
if (oldList.isEmpty()) {
L.d("Short-circuiting diff to insert all")
logD("Short-circuiting diff to insert all")
currentList = newList
// notify last, after list is updated
updateCallback.onInserted(0, newList.size)
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) {
L.d("Applying calculated diff")
logD("Applying calculated diff")
currentList = newList
result.dispatchUpdatesTo(updateCallback)
callback?.invoke()

View file

@ -21,7 +21,8 @@ package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
@ -58,7 +59,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPlaying(item: T?, isPlaying: Boolean) {
L.d("Updating playing item [old: $currentItem new: $item]")
logD("Updating playing item [old: $currentItem new: $item]")
var updatedItem = false
if (currentItem != item) {
@ -71,7 +72,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
L.w("oldItem was not in adapter data")
logW("oldItem was not in adapter data")
}
}
@ -81,7 +82,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
L.w("newItem was not in adapter data")
logW("newItem was not in adapter data")
}
}
@ -99,7 +100,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
L.w("newItem was not in adapter data")
logW("newItem was not in adapter data")
}
}
}

View file

@ -21,8 +21,8 @@ package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.musikr.Music
import timber.log.Timber as L
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.logD
/**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
@ -55,7 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
// Nothing to do.
return
}
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
selectedItems = newSelectedItems
for (i in currentList.indices) {

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable
import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* Command to navigate to a specific menu dialog configuration.

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of
@ -102,7 +102,7 @@ abstract class MenuDialogFragment<M : Menu> :
private fun updateMenu(menu: Menu?) {
if (menu == null) {
L.d("No menu to show, navigating away")
logD("No menu to show, navigating away")
findNavController().navigateUp()
return
}

View file

@ -27,18 +27,17 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* [MenuDialogFragment] implementation for a [Song].
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
val context = requireContext()
binding.menuCover.bind(menu.album)
binding.menuType.text = menu.album.releaseType.resolve(context)
binding.menuType.text = getString(menu.album.releaseType.stringRes)
binding.menuName.text = menu.album.name.resolve(context)
binding.menuInfo.text = menu.album.artists.resolveNames(context)
}

View file

@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
oldItem == newItem
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
oldItem.title.toString() == newItem.title.toString()
oldItem.title == newItem.title
}
}
}

View file

@ -23,10 +23,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.MusicParent
import timber.log.Timber as L
import org.oxycblt.auxio.util.logW
/**
* Manages the state information for [MenuDialogFragment] implementations.
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
fun setMenu(parcel: Menu.Parcel) {
_currentMenu.value = unpackParcel(parcel)
if (_currentMenu.value == null) {
L.w("Given menu parcel $parcel was invalid")
logW("Given menu parcel $parcel was invalid")
}
}
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
}
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null
val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith)
}
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
val album = musicRepository.library?.findAlbum(parcel.albumUid) ?: return null
val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
return Menu.ForAlbum(parcel.res, album)
}
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
val artist = musicRepository.library?.findArtist(parcel.artistUid) ?: return null
val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
return Menu.ForArtist(parcel.res, artist)
}
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
val genre = musicRepository.library?.findGenre(parcel.genreUid) ?: return null
val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
return Menu.ForGenre(parcel.res, genre)
}
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
val playlist = musicRepository.library?.findPlaylist(parcel.playlistUid) ?: return null
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
return Menu.ForPlaylist(parcel.res, playlist)
}
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val library = musicRepository.library ?: return null
val songs = parcel.songUids.mapNotNull(library::findSong)
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
return Menu.ForSelection(parcel.res, songs)
}
}

View file

@ -19,7 +19,6 @@
package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet
import android.view.WindowInsets
import androidx.annotation.AttrRes
@ -39,7 +38,6 @@ open class AuxioRecyclerView
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) {
private val initialPaddingBottom = paddingBottom
private var savedState: Parcelable? = null
init {
// Prevent children from being clipped by window insets
@ -62,18 +60,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Update the RecyclerView's padding such that the bottom insets are applied
// while still preserving bottom padding.
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
if (savedState != null) {
// State restore happens before we get insets, so there will be scroll drift unless
// we restore the state after the insets are applied.
// We must only do this once, otherwise we'll get jumpy behavior.
super.onRestoreInstanceState(savedState)
savedState = null
}
return insets
}
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState(state)
savedState = state
}
}

View file

@ -25,7 +25,6 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@ -34,7 +33,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger
import timber.log.Timber as L
import org.oxycblt.auxio.util.logD
/**
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
@ -92,11 +91,12 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
L.d("Lifting ViewHolder")
logD("Lifting ViewHolder")
val bg = holder.background
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
holder.root
.animate()
.translationZ(elevation)
@ -135,10 +135,10 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero.
if (holder.root.translationZ != 0f) {
L.d("Lifting ViewHolder")
logD("Lifting ViewHolder")
val bg = holder.background
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
holder.root
.animate()
.translationZ(0f)

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.list.recycler
import android.annotation.SuppressLint
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
@ -28,21 +27,20 @@ import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
@ -362,7 +360,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
}
/**
* A [RecyclerView.ViewHolder] that displays a [PlainDivider]. Use [from] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -383,9 +381,8 @@ class DividerViewHolder private constructor(divider: MaterialDivider) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<PlainDivider>() {
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: PlainDivider, newItem: PlainDivider) =
object : SimpleDiffCallback<Divider>() {
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
oldItem.anchor == newItem.anchor
}
}

View file

@ -18,13 +18,17 @@
package org.oxycblt.auxio.list.sort
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
/**
* A sorting method.
@ -42,9 +46,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration.
*/
fun songs(songs: Collection<Song>): List<Song> {
fun <T : Song> songs(songs: Collection<T>): List<T> {
val mutable = songs.toMutableList()
mode.sortSongs(mutable, direction)
songsInPlace(mutable)
return mutable
}
@ -54,9 +58,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration.
*/
fun albums(albums: Collection<Album>): List<Album> {
fun <T : Album> albums(albums: Collection<T>): List<T> {
val mutable = albums.toMutableList()
mode.sortAlbums(mutable, direction)
albumsInPlace(mutable)
return mutable
}
@ -66,9 +70,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration.
*/
fun artists(artists: Collection<Artist>): List<Artist> {
fun <T : Artist> artists(artists: Collection<T>): List<T> {
val mutable = artists.toMutableList()
mode.sortArtists(mutable, direction)
artistsInPlace(mutable)
return mutable
}
@ -78,9 +82,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration.
*/
fun genres(genres: Collection<Genre>): List<Genre> {
fun <T : Genre> genres(genres: Collection<T>): List<T> {
val mutable = genres.toMutableList()
mode.sortGenres(mutable, direction)
genresInPlace(mutable)
return mutable
}
@ -90,12 +94,37 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @param playlists The list of [Playlist]s.
* @return A new list of [Playlist]s sorted by this [Sort]'s configuration
*/
fun playlists(playlists: Collection<Playlist>): List<Playlist> {
fun <T : Playlist> playlists(playlists: Collection<T>): List<T> {
val mutable = playlists.toMutableList()
mode.sortPlaylists(mutable, direction)
playlistsInPlace(mutable)
return mutable
}
private fun songsInPlace(songs: MutableList<out Song>) {
val comparator = mode.getSongComparator(direction) ?: return
songs.sortWith(comparator)
}
private fun albumsInPlace(albums: MutableList<out Album>) {
val comparator = mode.getAlbumComparator(direction) ?: return
albums.sortWith(comparator)
}
private fun artistsInPlace(artists: MutableList<out Artist>) {
val comparator = mode.getArtistComparator(direction) ?: return
artists.sortWith(comparator)
}
private fun genresInPlace(genres: MutableList<out Genre>) {
val comparator = mode.getGenreComparator(direction) ?: return
genres.sortWith(comparator)
}
private fun playlistsInPlace(playlists: MutableList<out Playlist>) {
val comparator = mode.getPlaylistComparator(direction) ?: return
playlists.sortWith(comparator)
}
/**
* The integer representation of this instance.
*
@ -112,270 +141,289 @@ data class Sort(val mode: Mode, val direction: Direction) {
Direction.DESCENDING -> 0
}
/** Describes the type of data to sort with. */
sealed interface Mode {
/** The integer representation of this sort mode. */
val intCode: Int
/** The string resource of the human-readable name of this sort mode. */
val stringRes: Int
fun sortSongs(songs: MutableList<Song>, direction: Direction) {
throw NotImplementedError("Sorting songs is not supported for this mode")
}
/**
* Get a [Comparator] that sorts [Song]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode],
* or null to not sort at all.
*/
fun getSongComparator(direction: Direction): Comparator<Song>? = null
fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
throw NotImplementedError("Sorting albums is not supported for this mode")
}
/**
* Get a [Comparator] that sorts [Album]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode],
* or null to not sort at all.
*/
fun getAlbumComparator(direction: Direction): Comparator<Album>? = null
fun sortArtists(artists: MutableList<Artist>, direction: Direction) {
throw NotImplementedError("Sorting artists is not supported for this mode")
}
/**
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
* or null to not sort at all.
*/
fun getArtistComparator(direction: Direction): Comparator<Artist>? = null
fun sortGenres(genres: MutableList<Genre>, direction: Direction) {
throw NotImplementedError("Sorting genres is not supported for this mode")
}
/**
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
* or null to not sort at all.
*/
fun getGenreComparator(direction: Direction): Comparator<Genre>? = null
fun sortPlaylists(playlists: MutableList<Playlist>, direction: Direction) {
throw NotImplementedError("Sorting playlists is not supported for this mode")
}
/**
* Return a [Comparator] that sorts [Playlist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
* or null to not sort at all.
*/
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null
/**
* Sort by the item's name.
*
* @see Music.name
*/
data object ByName : Mode {
override val intCode = IntegerTable.SORT_BY_NAME
override val stringRes = R.string.lbl_name
override val intCode: Int
get() = IntegerTable.SORT_BY_NAME
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.name }
Direction.DESCENDING -> songs.sortByDescending { it.name }
}
}
override val stringRes: Int
get() = R.string.lbl_name
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.name }
Direction.DESCENDING -> albums.sortByDescending { it.name }
}
}
override fun getSongComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.SONG)
override fun sortArtists(artists: MutableList<Artist>, direction: Direction) {
when (direction) {
Direction.ASCENDING -> artists.sortBy { it.name }
Direction.DESCENDING -> artists.sortByDescending { it.name }
}
}
override fun getAlbumComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.ALBUM)
override fun sortGenres(genres: MutableList<Genre>, direction: Direction) {
when (direction) {
Direction.ASCENDING -> genres.sortBy { it.name }
Direction.DESCENDING -> genres.sortByDescending { it.name }
}
}
override fun getArtistComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.ARTIST)
override fun sortPlaylists(playlists: MutableList<Playlist>, direction: Direction) {
when (direction) {
Direction.ASCENDING -> playlists.sortBy { it.name }
Direction.DESCENDING -> playlists.sortByDescending { it.name }
}
}
override fun getGenreComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.GENRE)
override fun getPlaylistComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.PLAYLIST)
}
/**
* Sort by the [Album] of an item. Only available for [Song]s.
*
* @see Album.name
*/
data object ByAlbum : Mode {
override val intCode = IntegerTable.SORT_BY_ALBUM
override val stringRes = R.string.lbl_album
override val intCode: Int
get() = IntegerTable.SORT_BY_ALBUM
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
songs.sortBy { it.track }
songs.sortBy { it.disc }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.album.name }
Direction.DESCENDING -> songs.sortByDescending { it.album.name }
}
}
override val stringRes: Int
get() = R.string.lbl_album
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction, BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
/**
* Sort by the [Artist] name of an item. Only available for [Song] and [Album].
*
* @see Artist.name
*/
data object ByArtist : Mode {
override val intCode = IntegerTable.SORT_BY_ARTIST
override val stringRes = R.string.lbl_artist
override val intCode: Int
get() = IntegerTable.SORT_BY_ARTIST
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
songs.sortBy { it.track }
songs.sortBy { it.disc }
songs.sortBy { it.album.name }
songs.sortByDescending { it.album.dates }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.artists.firstOrNull()?.name }
Direction.DESCENDING ->
songs.sortByDescending { it.artists.firstOrNull()?.name }
}
}
override val stringRes: Int
get() = R.string.lbl_artist
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
albums.sortByDescending { it.dates }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.artists.firstOrNull()?.name }
Direction.DESCENDING ->
albums.sortByDescending { it.artists.firstOrNull()?.name }
}
}
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator(
compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
/**
* Sort by the [Date] of an item. Only available for [Song] and [Album].
*
* @see Song.date
* @see Album.dates
*/
data object ByDate : Mode {
override val intCode = IntegerTable.SORT_BY_YEAR
override val stringRes = R.string.lbl_date
override val intCode: Int
get() = IntegerTable.SORT_BY_YEAR
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
songs.sortBy { it.track }
songs.sortBy { it.disc }
songs.sortByDescending { it.album.name }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.album.dates }
Direction.DESCENDING -> songs.sortByDescending { it.album.dates }
}
}
override val stringRes: Int
get() = R.string.lbl_date
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.dates }
Direction.DESCENDING -> albums.sortByDescending { it.dates }
}
}
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator(
compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
/** Sort by the duration of an item. */
data object ByDuration : Mode {
override val intCode = IntegerTable.SORT_BY_DURATION
override val stringRes = R.string.lbl_duration
override val intCode: Int
get() = IntegerTable.SORT_BY_DURATION
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.durationMs }
Direction.DESCENDING -> songs.sortByDescending { it.durationMs }
}
}
override val stringRes: Int
get() = R.string.lbl_duration
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.durationMs }
Direction.DESCENDING -> albums.sortByDescending { it.durationMs }
}
}
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.SONG))
override fun sortArtists(artists: MutableList<Artist>, direction: Direction) {
artists.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> artists.sortBy { it.durationMs }
Direction.DESCENDING -> artists.sortByDescending { it.durationMs }
}
}
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.ALBUM))
override fun sortGenres(genres: MutableList<Genre>, direction: Direction) {
genres.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> genres.sortBy { it.durationMs }
Direction.DESCENDING -> genres.sortByDescending { it.durationMs }
}
}
override fun getArtistComparator(direction: Direction): Comparator<Artist> =
MultiComparator(
compareByDynamic(direction, NullableComparator.LONG) { it.durationMs },
compareBy(BasicComparator.ARTIST))
override fun sortPlaylists(playlists: MutableList<Playlist>, direction: Direction) {
playlists.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> playlists.sortBy { it.durationMs }
Direction.DESCENDING -> playlists.sortByDescending { it.durationMs }
}
}
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.durationMs },
compareBy(BasicComparator.PLAYLIST))
}
/** Sort by the amount of songs an item contains. Only available for MusicParents. */
data object ByCount : Mode {
override val intCode = IntegerTable.SORT_BY_COUNT
override val stringRes = R.string.lbl_song_count
override val intCode: Int
get() = IntegerTable.SORT_BY_COUNT
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.songs.size }
Direction.DESCENDING -> albums.sortByDescending { it.songs.size }
}
}
override val stringRes: Int
get() = R.string.lbl_song_count
override fun sortArtists(artists: MutableList<Artist>, direction: Direction) {
artists.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> artists.sortBy { it.songs.size }
Direction.DESCENDING -> artists.sortByDescending { it.songs.size }
}
}
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator(
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.ALBUM))
override fun sortGenres(genres: MutableList<Genre>, direction: Direction) {
genres.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> genres.sortBy { it.songs.size }
Direction.DESCENDING -> genres.sortByDescending { it.songs.size }
}
}
override fun getArtistComparator(direction: Direction): Comparator<Artist> =
MultiComparator(
compareByDynamic(direction, NullableComparator.INT) { it.songs.size },
compareBy(BasicComparator.ARTIST))
override fun sortPlaylists(playlists: MutableList<Playlist>, direction: Direction) {
playlists.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> playlists.sortBy { it.songs.size }
Direction.DESCENDING -> playlists.sortByDescending { it.songs.size }
}
}
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.songs.size },
compareBy(BasicComparator.PLAYLIST))
}
/**
* Sort by the disc number of an item. Only available for [Song]s.
*
* @see Song.disc
*/
data object ByDisc : Mode {
override val intCode = IntegerTable.SORT_BY_DISC
override val stringRes = R.string.lbl_disc
override val intCode: Int
get() = IntegerTable.SORT_BY_DISC
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
songs.sortBy { it.track }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.disc }
Direction.DESCENDING -> songs.sortByDescending { it.disc }
}
}
override val stringRes: Int
get() = R.string.lbl_disc
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction, NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
/**
* Sort by the track number of an item. Only available for [Song]s.
*
* @see Song.track
*/
data object ByTrack : Mode {
override val intCode = IntegerTable.SORT_BY_TRACK
override val stringRes = R.string.lbl_track
override val intCode: Int
get() = IntegerTable.SORT_BY_TRACK
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.track }
Direction.DESCENDING -> songs.sortByDescending { it.track }
}
songs.sortBy { it.disc }
}
override val stringRes: Int
get() = R.string.lbl_track
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareBy(NullableComparator.DISC) { it.disc },
compareByDynamic(direction, NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
/**
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
*
* @see Song.dateAdded
* @see Album.dates
*/
data object ByDateAdded : Mode {
override val intCode = IntegerTable.SORT_BY_DATE_ADDED
override val stringRes = R.string.lbl_date_added
override val intCode: Int
get() = IntegerTable.SORT_BY_DATE_ADDED
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.addedMs }
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
}
}
override val stringRes: Int
get() = R.string.lbl_date_added
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.addedMs }
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
}
}
override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator(
compareByDynamic(direction) { it.dateAdded }, compareBy(BasicComparator.SONG))
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator(
compareByDynamic(direction) { album -> album.dateAdded },
compareBy(BasicComparator.ALBUM))
}
companion object {
fun fromIntCode(intCode: Int): Mode? =
/**
* Convert a [Mode] integer representation into an instance.
*
* @param intCode An integer representation of a [Mode]
* @return The corresponding [Mode], or null if the [Mode] is invalid.
* @see intCode
*/
fun fromIntCode(intCode: Int) =
when (intCode) {
ByName.intCode -> ByName
ByArtist.intCode -> ByArtist
@ -415,3 +463,166 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
}
}
/**
* Utility function to create a [Comparator] in a dynamic way determined by [direction].
*
* @param direction The [Sort.Direction] to sort in.
* @see compareBy
* @see compareByDescending
*/
private inline fun <T : Music, K : Comparable<K>> compareByDynamic(
direction: Sort.Direction,
crossinline selector: (T) -> K
) =
when (direction) {
Sort.Direction.ASCENDING -> compareBy(selector)
Sort.Direction.DESCENDING -> compareByDescending(selector)
}
/**
* Utility function to create a [Comparator] in a dynamic way determined by [direction]
*
* @param direction The [Sort.Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
private fun <T : Music> compareByDynamic(
direction: Sort.Direction,
comparator: Comparator<in T>
): Comparator<T> = compareByDynamic(direction, comparator) { it }
/**
* Utility function to create a [Comparator] a dynamic way determined by [direction]
*
* @param direction The [Sort.Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @param selector Called to obtain a specific attribute to sort by.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
private inline fun <T : Music, K> compareByDynamic(
direction: Sort.Direction,
comparator: Comparator<in K>,
crossinline selector: (T) -> K
) =
when (direction) {
Sort.Direction.ASCENDING -> compareBy(comparator, selector)
Sort.Direction.DESCENDING -> compareByDescending(comparator, selector)
}
/**
* Utility function to create a [Comparator] that sorts in ascending order based on the given
* [Comparator], with a selector based on the item itself.
*
* @param comparator The [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
*/
private fun <T : Music> compareBy(comparator: Comparator<T>): Comparator<T> =
compareBy(comparator) { it }
/**
* A [Comparator] that chains several other [Comparator]s together to form one comparison.
*
* @param comparators The [Comparator]s to chain. These will be iterated through in order during a
* comparison, with the first non-equal result becoming the result.
*/
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
private val _comparators = comparators
override fun compare(a: T?, b: T?): Int {
for (comparator in _comparators) {
val result = comparator.compare(a, b)
if (result != 0) {
return result
}
}
return 0
}
}
/**
* Wraps a [Comparator], extending it to compare two lists.
*
* @param inner The [Comparator] to use.
*/
private class ListComparator<T>(private val inner: Comparator<T>) : Comparator<List<T>> {
override fun compare(a: List<T>, b: List<T>): Int {
for (i in 0 until max(a.size, b.size)) {
val ai = a.getOrNull(i)
val bi = b.getOrNull(i)
when {
ai != null && bi != null -> {
val result = inner.compare(ai, bi)
if (result != 0) {
return result
}
}
ai == null && bi != null -> return -1 // a < b
ai == null && bi == null -> return 0 // a = b
else -> return 1 // a < b
}
}
return 0
}
companion object {
/** A re-usable configured for [Artist]s.. */
val ARTISTS: Comparator<List<Artist>> = ListComparator(BasicComparator.ARTIST)
}
}
/**
* A [Comparator] that compares abstract [Music] values. Internally, this is similar to
* [NullableComparator], however comparing [Music.name] instead of [Comparable].
*
* @see NullableComparator
* @see Music.name
*/
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T) = a.name.compareTo(b.name)
companion object {
/** A re-usable instance configured for [Song]s. */
val SONG: Comparator<Song> = BasicComparator()
/** A re-usable instance configured for [Album]s. */
val ALBUM: Comparator<Album> = BasicComparator()
/** A re-usable instance configured for [Artist]s. */
val ARTIST: Comparator<Artist> = BasicComparator()
/** A re-usable instance configured for [Genre]s. */
val GENRE: Comparator<Genre> = BasicComparator()
/** A re-usable instance configured for [Playlist]s. */
val PLAYLIST: Comparator<Playlist> = BasicComparator()
}
}
/**
* A [Comparator] that compares two possibly null values. Values will be considered lesser if they
* are null, and greater if they are non-null.
*/
private class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
override fun compare(a: T?, b: T?) =
when {
a != null && b != null -> a.compareTo(b)
a == null && b != null -> -1 // a < b
a == null && b == null -> 0 // a = b
else -> 1 // a < b
}
companion object {
/** A re-usable instance configured for [Int]s. */
val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Disc]s */
val DISC = NullableComparator<Disc>()
/** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator<Date.Range>()
}
}

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