Compare commits
No commits in common. "dev" and "v3.2.0" have entirely different histories.
678 changed files with 17837 additions and 33556 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1,2 +0,0 @@
|
|||
github: [OxygenCobalt]
|
||||
custom: ["https://paypal.me/oxycblt"]
|
||||
9
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
|
@ -34,8 +34,6 @@ body:
|
|||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 15
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12L
|
||||
- Android 12
|
||||
|
|
@ -58,13 +56,6 @@ body:
|
|||
placeholder: OnePlus 7T (LineageOS)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: sample-file
|
||||
attributes:
|
||||
label: Provide a sample file
|
||||
description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.*
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
|
|
|||
20
.github/workflows/android.yml
vendored
20
.github/workflows/android.yml
vendored
|
|
@ -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
|
||||
run: git submodule update --init --recursive
|
||||
- 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
3
.gitignore
vendored
|
|
@ -13,6 +13,3 @@ captures/
|
|||
.externalNativeBuild
|
||||
*.iml
|
||||
.cxx
|
||||
.kotlin
|
||||
.aider*
|
||||
.env
|
||||
|
|
|
|||
5
.gitmodules
vendored
5
.gitmodules
vendored
|
|
@ -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
|
||||
|
|
|
|||
315
CHANGELOG.md
315
CHANGELOG.md
|
|
@ -1,316 +1,5 @@
|
|||
# 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
|
||||
|
||||
#### 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
|
||||
- Added back option disable ReplayGain for poorly tagged libraries
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed crash when using play next on the end of a queue or with a single-song queue
|
||||
- Fixed weird behavior if using play next on the end of a queue with repeat all enabled
|
||||
- Fixed artist choice dialog not showing up on home screen if playing from artist/genre was enabled
|
||||
|
||||
## 3.4.2
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed "Add to queue" incorrectly changing the queue and crashing the app
|
||||
- Fixed 1x4 and 1x3 widgets having square edges
|
||||
- Fixed crash when music library updates in such a way to change music information
|
||||
- Fixed crash when music library updates while scrolled in a list
|
||||
- Fixed inconsistent corner radius in wafer widgets
|
||||
|
||||
## 3.4.1
|
||||
|
||||
#### What's Fixed
|
||||
- R128 adjustments are now adjusted to -18 LUFS to be consistent with MP3
|
||||
- Fixed double application of opus base gain
|
||||
- Fixed playback state not restoring
|
||||
|
||||
## 3.4.0
|
||||
|
||||
#### What's New
|
||||
- Gapless playback is now used whenever possible
|
||||
- Added "Remember pause" setting that makes remain paused when skipping
|
||||
or editing queue
|
||||
- Added 1x4 and 1x3 widget forms
|
||||
|
||||
#### What's Fixed
|
||||
- Increased music timeout to 60 seconds to accomodate large cover arts
|
||||
on slow storage drives
|
||||
- Fixed app repeatedly crashing when automatic theme was on
|
||||
|
||||
#### What's Improved
|
||||
- The playback state is now saved more often, improving persistence
|
||||
- The queue is now fully circular when repeat all is enabled
|
||||
|
||||
#### What's Changed
|
||||
- You can no longer save, restore, or clear the playback state
|
||||
- The playback session now ends if you swipe away the app while it's paused
|
||||
|
||||
## 3.3.3
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music folders not behaving correctly below Android 11
|
||||
|
||||
## 3.3.2
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading failing with an SQL error with certain music folder configurations
|
||||
|
||||
## 3.3.1
|
||||
|
||||
#### What's Improved
|
||||
- The OPUS base volume adjustment field is now parsed and used as a ReplayGain adjustment
|
||||
- Added ReplayGain adjustment values to Song Properties dialog
|
||||
|
||||
#### What's Changed
|
||||
- Added donation links to the about page
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed a crash occuring if you navigated to the settings page from the playlist view
|
||||
and then back
|
||||
- Fixed music loading failing with an SQL error with certain music folder configurations
|
||||
- Fixed issue where song title on playback screen would not scroll
|
||||
|
||||
## 3.3.0
|
||||
|
||||
#### What's New
|
||||
- Added ability to rewind/skip tracks by swiping back/forward
|
||||
- Added support for demo release type
|
||||
- Added playlist importing/export from M3U files
|
||||
|
||||
#### What's Improved
|
||||
- Music loading will now fail when it hangs
|
||||
|
||||
#### What's Changed
|
||||
- Albums linked to an artist only as a collaborator are no longer included
|
||||
in an artist's album count
|
||||
- File name and parent path have been combined into "Path" in the Song Properties
|
||||
view
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading failing on all huawei devices
|
||||
- Fixed prior music loads not cancelling when reloading music in settings
|
||||
- Fixed certain FLAC files failing to play on some devices
|
||||
- Fixed music loading failing when duplicate tags with different casing was present
|
||||
|
||||
#### Dev/Meta
|
||||
- Revamped path management
|
||||
|
||||
|
||||
## 3.2.1
|
||||
|
||||
#### What's Improved
|
||||
- Added support for native M4A multi-value tags based on duplicate atoms
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed app restart being required when changing intelligent sorting
|
||||
or music separator settings
|
||||
- Fixed widget/notification actions not working on Android 14
|
||||
- Fixed app crash when using hebrew language
|
||||
- Fixed app crash when adding to a playlist while in the playlist detail view
|
||||
- Fixed music loading failing in some cases on Android 14
|
||||
|
||||
## 3.2.0
|
||||
|
||||
#### What's New
|
||||
|
|
@ -327,10 +16,6 @@ aspect ratio setting
|
|||
#### What's Fixed
|
||||
- Playlist detail view now respects playback settings
|
||||
|
||||
|
||||
#### Dev/Meta
|
||||
- Revamped navigation backend
|
||||
|
||||
## 3.1.4
|
||||
|
||||
#### What's Fixed
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -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.2.0">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.2.0&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">
|
||||
|
|
@ -13,35 +13,33 @@
|
|||
</a>
|
||||
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-24%2B-1450A8?style=flat">
|
||||
</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>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</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://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
<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 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
</p>
|
||||
|
||||
## About
|
||||
|
||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
||||
|
||||
I primarily built Auxio for myself, but you can use it too, I guess.
|
||||
|
||||
**The default branch is the development version of the repository. For a stable version, see the master branch.**
|
||||
|
||||
## 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>
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Playback based on [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer)
|
||||
|
|
@ -54,8 +52,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)
|
||||
- Edge-to-edge
|
||||
|
|
@ -64,39 +60,16 @@ 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 (Unless you want them. Then you can.)
|
||||
|
||||
## 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
|
||||
|
||||
## Donate
|
||||
|
||||
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>$8/month supporters:</b></p>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
|
||||
|
||||
## 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.
|
||||
|
|
@ -111,8 +84,6 @@ However, feature additions and major UI changes are less likely to be accepted.
|
|||
[Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F)
|
||||
for more information.
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
|
|
|||
|
|
@ -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,25 +11,26 @@ 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 = "23.2.8568313"
|
||||
namespace "org.oxycblt.auxio"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "4.0.4"
|
||||
versionCode 63
|
||||
versionName "3.2.0"
|
||||
versionCode 35
|
||||
|
||||
minSdk min_sdk
|
||||
targetSdk target_sdk
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
|
@ -67,7 +69,6 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,17 +78,17 @@ 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.fragment:fragment-ktx:1.6.2"
|
||||
implementation "androidx.core:core-ktx:1.10.1"
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.1"
|
||||
|
||||
// Components
|
||||
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
|
||||
|
|
@ -95,13 +96,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.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
|
|
@ -112,27 +111,17 @@ dependencies {
|
|||
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
|
||||
|
||||
// Media
|
||||
implementation "androidx.media:media:1.7.0"
|
||||
|
||||
// Android Auto
|
||||
implementation "androidx.car.app:app:1.4.0"
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
|
||||
// Preferences
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
// Database
|
||||
def room_version = '2.6.0-alpha03'
|
||||
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)
|
||||
|
|
@ -140,12 +129,12 @@ dependencies {
|
|||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
|
||||
// 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-alpha06"
|
||||
|
||||
// Dependency Injection
|
||||
implementation "com.google.dagger:dagger:$hilt_version"
|
||||
|
|
@ -153,15 +142,21 @@ dependencies {
|
|||
implementation "com.google.dagger:hilt-android:$hilt_version"
|
||||
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
||||
|
||||
// Logging
|
||||
implementation 'com.jakewharton.timber:timber:5.0.1'
|
||||
|
||||
// Speed dial
|
||||
implementation "com.leinardi.android:speed-dial:3.3.0"
|
||||
|
||||
// Tasker integration
|
||||
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"
|
||||
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
|
||||
}
|
||||
|
|
|
|||
40
app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
Normal file
40
app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* StubTest.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
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StubTest {
|
||||
// TODO: Make tests
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("org.oxycblt.auxio.debug", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -2,11 +2,13 @@
|
|||
<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" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->
|
||||
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />-->
|
||||
|
|
@ -34,18 +36,11 @@
|
|||
android:enableOnBackInvokedCallback="true"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
<meta-data
|
||||
android:name="androidx.car.app.TintableAttributionIcon"
|
||||
android:resource="@drawable/ic_auxio_24" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:launchMode="singleTask"
|
||||
android:allowCrossUidActivitySwitchFromBelow="false"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
|
||||
|
|
@ -81,37 +76,33 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Service handling querying the media database, extracting metadata, and constructing
|
||||
the music library.
|
||||
-->
|
||||
<service
|
||||
android:name=".music.system.IndexerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
<!--
|
||||
Service handling music playback, system components, and state saving.
|
||||
-->
|
||||
<service
|
||||
android:name=".AuxioService"
|
||||
android:name=".playback.system.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<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" />
|
||||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
<!--
|
||||
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||
See the class for more info.
|
||||
-->
|
||||
<receiver
|
||||
android:name=".playback.service.MediaButtonReceiver"
|
||||
android:name=".playback.system.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
|
|
@ -141,16 +132,5 @@
|
|||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Tasker 'start service' integration -->
|
||||
<activity
|
||||
android:name=".tasker.ActivityConfigStartAction"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/lbl_start_playback">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -29,8 +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
|
||||
|
||||
/**
|
||||
* A simple, rational music player for android.
|
||||
|
|
@ -46,14 +44,6 @@ 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) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
// Migrate any settings that may have changed in an app update.
|
||||
imageSettings.migrate()
|
||||
playbackSettings.migrate()
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* AuxioService.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
|
||||
|
||||
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 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
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService :
|
||||
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||
|
||||
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||
private lateinit var musicFragment: MusicServiceFragment
|
||||
|
||||
@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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
playbackFragment.handleTaskRemoved()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
musicFragment.release()
|
||||
playbackFragment.release()
|
||||
}
|
||||
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot {
|
||||
return musicFragment.getRoot()
|
||||
}
|
||||
|
||||
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 updateForeground(change: ForegroundListener.Change) {
|
||||
val mediaNotification = playbackFragment.notification
|
||||
if (mediaNotification != null) {
|
||||
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||
startForeground(mediaNotification.code, mediaNotification.build())
|
||||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
isForeground = true
|
||||
} else {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
startForeground(it.code, it.build())
|
||||
isForeground = true
|
||||
} else {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
interface ForegroundListener {
|
||||
fun updateForeground(change: Change)
|
||||
|
||||
enum class Change {
|
||||
MEDIA_SESSION,
|
||||
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)
|
||||
}
|
||||
|
|
@ -49,10 +49,8 @@ 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 */
|
||||
|
|
@ -61,12 +59,6 @@ object IntegerTable {
|
|||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||
/** 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 */
|
||||
|
|
@ -110,7 +102,7 @@ object IntegerTable {
|
|||
/** Sort.Mode.ByDateAdded */
|
||||
const val SORT_BY_DATE_ADDED = 0xA118
|
||||
/** ReplayGainMode.Off (No longer used but still reserved) */
|
||||
const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||
/** ReplayGainMode.Track */
|
||||
const val REPLAY_GAIN_MODE_TRACK = 0xA111
|
||||
/** ReplayGainMode.Album */
|
||||
|
|
@ -125,10 +117,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 +133,4 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ import androidx.core.view.updatePadding
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
||||
import org.oxycblt.auxio.music.system.IndexerService
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
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,24 +65,22 @@ class MainActivity : AppCompatActivity() {
|
|||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupEdgeToEdge(binding.root)
|
||||
L.d("Activity created")
|
||||
logD("Activity created")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
startService(
|
||||
Intent(this, AuxioService::class.java)
|
||||
.setAction(AuxioService.ACTION_START)
|
||||
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||
startService(Intent(this, IndexerService::class.java))
|
||||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
|
||||
playbackModel.startAction(InternalPlayer.Action.RestoreState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
startIntentAction(intent)
|
||||
}
|
||||
|
|
@ -90,10 +91,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,17 +111,17 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Transform an [Intent] given to [MainActivity] into a [DeferredPlayback] that can be used in
|
||||
* the playback system.
|
||||
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
|
||||
* in the playback system.
|
||||
*
|
||||
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
|
||||
* @return true If the analogous [DeferredPlayback] to the given [Intent] was started, false
|
||||
* otherwise.
|
||||
* @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
|
||||
* false otherwise.
|
||||
*/
|
||||
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,22 +130,22 @@ 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)
|
||||
|
||||
val action =
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
|
||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
|
||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
||||
else -> {
|
||||
L.w("Unexpected intent ${intent.action}")
|
||||
logW("Unexpected intent ${intent.action}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
L.d("Translated intent to $action")
|
||||
playbackModel.playDeferred(action)
|
||||
logD("Translated intent to $action")
|
||||
playbackModel.startAction(action)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,25 +22,18 @@ 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 dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Method
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
|
|
@ -49,15 +42,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 +56,10 @@ 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.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 +68,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()
|
||||
|
|
@ -90,14 +76,9 @@ class MainFragment :
|
|||
private var sheetBackCallback: SheetBackPressedCallback? = null
|
||||
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 +93,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 +109,8 @@ class MainFragment :
|
|||
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||
val selectionBackCallback =
|
||||
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
||||
speedDialBackCallback = SpeedDialBackPressedCallback()
|
||||
|
||||
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
|
||||
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
|
|
@ -151,51 +128,28 @@ 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)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// This has to be done here instead of the playback panel to make sure that it's prioritized
|
||||
// by StateFlow over any detail fragment.
|
||||
|
|
@ -204,9 +158,6 @@ 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(listModel.selected, selectionBackCallback::invalidateEnabled)
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
|
||||
|
|
@ -217,7 +168,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.
|
||||
|
|
@ -230,7 +181,6 @@ class MainFragment :
|
|||
// navigation, navigation out of detail views, etc. We have to do this here in
|
||||
// onResume or otherwise the FragmentManager will have precedence.
|
||||
requireActivity().onBackPressedDispatcher.apply {
|
||||
addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
|
||||
|
|
@ -240,27 +190,20 @@ 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)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentMainBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
speedDialBackCallback = null
|
||||
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!
|
||||
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
// probably be cheap enough.
|
||||
|
|
@ -271,55 +214,21 @@ 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)
|
||||
}
|
||||
|
||||
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 +238,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 +272,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 +291,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,
|
||||
|
|
@ -556,7 +329,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 +345,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 +356,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 +367,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 +393,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 +414,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 +433,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 +468,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (detailModel.dropPlaylistEdit()) {
|
||||
L.d("Dropped playlist edits")
|
||||
logD("Dropped playlist edits")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -738,7 +481,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (listModel.dropSelection()) {
|
||||
L.d("Dropped selection")
|
||||
logD("Dropped selection")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -746,26 +489,4 @@ class MainFragment :
|
|||
isEnabled = selection.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeNewPlaylistFab.isOpen) {
|
||||
binding.homeNewPlaylistFab.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateEnabled(open: Boolean) {
|
||||
isEnabled = open
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FAB_HIDE_FROM_USER_FIELD: Method by
|
||||
lazyReflectedMethod(
|
||||
FloatingActionButton::class,
|
||||
"hide",
|
||||
FloatingActionButton.OnVisibilityChangedListener::class,
|
||||
Boolean::class)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,34 +19,43 @@
|
|||
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.showToast
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
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 +63,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)
|
||||
|
|
@ -74,7 +126,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
|
|
@ -82,6 +133,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 +144,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 +182,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 +191,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 +205,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,25 +268,17 @@ 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())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Import,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete,
|
||||
is PlaylistDecision.Export -> error("Unexpected playlist decision $decision")
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||
if (message == null) return
|
||||
requireContext().showToast(message.stringRes)
|
||||
musicModel.playlistMessage.consume()
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
albumListAdapter.setPlaying(
|
||||
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
||||
|
|
@ -278,11 +289,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 +307,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 +329,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,33 +19,42 @@
|
|||
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.showToast
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
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 +62,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)
|
||||
|
|
@ -73,7 +128,6 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
|
|
@ -81,6 +135,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 +150,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 +158,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 +188,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 +203,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 +212,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,25 +271,17 @@ 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())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Import,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Export,
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||
if (message == null) return
|
||||
requireContext().showToast(message.stringRes)
|
||||
musicModel.playlistMessage.consume()
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||
val playingItem =
|
||||
|
|
@ -309,7 +304,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
Normal file
169
app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,203 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,32 +19,42 @@
|
|||
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.showToast
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
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,27 +62,70 @@ 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)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
|
|
@ -80,6 +133,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 +148,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 +156,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 +185,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 +193,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 +201,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 -> {
|
||||
|
|
@ -227,30 +259,22 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
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())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Import,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Export,
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||
if (message == null) return
|
||||
requireContext().showToast(message.stringRes)
|
||||
musicModel.playlistMessage.consume()
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
||||
val playingItem =
|
||||
|
|
@ -271,7 +295,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")
|
||||
|
|
|
|||
|
|
@ -19,41 +19,45 @@
|
|||
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.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.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
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,58 +66,83 @@ 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)
|
||||
|
||||
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
|
||||
|
||||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
L.w("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
L.d("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)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
|
|
@ -161,97 +190,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 +233,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 +249,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 -> {
|
||||
|
|
@ -342,52 +316,24 @@ class PlaylistDetailFragment :
|
|||
updateMultiToolbar()
|
||||
}
|
||||
|
||||
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Import -> {
|
||||
L.d("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
}
|
||||
.launch(M3U.MIME_TYPE)
|
||||
musicModel.playlistDecision.consume()
|
||||
return
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
decision.applySongs.map { it.uid }.toTypedArray(),
|
||||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
logD("Renaming ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(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")
|
||||
PlaylistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.Add,
|
||||
is PlaylistDecision.New -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||
if (message == null) return
|
||||
requireContext().showToast(message.stringRes)
|
||||
musicModel.playlistMessage.consume()
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
// Prefer songs that are playing from this playlist.
|
||||
playlistListAdapter.setPlaying(
|
||||
|
|
@ -399,11 +345,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 +360,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,16 @@ 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.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 +70,71 @@ 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_file_name, song.path.name))
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_relative_path, song.path.parent.resolveName(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)))
|
||||
}
|
||||
},
|
||||
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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* FlipFloatingActionButton.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 android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An extension of [FloatingActionButton] that enables the ability to fade in and out between
|
||||
* several states, as in the Material Design 3 specification.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class FlipFloatingActionButton
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.floatingActionButtonStyle
|
||||
) : FloatingActionButton(context, attrs, defStyleAttr) {
|
||||
private var pendingConfig: PendingConfig? = null
|
||||
private var flipping = false
|
||||
|
||||
override fun show() {
|
||||
// Will already show eventually, need to do nothing.
|
||||
if (flipping) {
|
||||
logD("Already flipping, aborting show")
|
||||
return
|
||||
}
|
||||
// Apply the new configuration possibly set in flipTo. This should occur even if
|
||||
// a flip was canceled by a hide.
|
||||
pendingConfig?.run {
|
||||
this@FlipFloatingActionButton.logD("Applying pending configuration")
|
||||
setImageResource(iconRes)
|
||||
contentDescription = context.getString(contentDescriptionRes)
|
||||
setOnClickListener(clickListener)
|
||||
}
|
||||
pendingConfig = null
|
||||
logD("Beginning show")
|
||||
super.show()
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
if (flipping) {
|
||||
logD("Hide was called, aborting flip")
|
||||
}
|
||||
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
|
||||
flipping = false
|
||||
// Don't pass any kind of listener so that future flip operations will not be able
|
||||
// to show the FAB again.
|
||||
logD("Beginning hide")
|
||||
super.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip to a new FAB state.
|
||||
*
|
||||
* @param iconRes The resource of the new FAB icon.
|
||||
* @param contentDescriptionRes The resource of the new FAB content description.
|
||||
*/
|
||||
fun flipTo(
|
||||
@DrawableRes iconRes: Int,
|
||||
@StringRes contentDescriptionRes: Int,
|
||||
clickListener: OnClickListener
|
||||
) {
|
||||
// Avoid doing a flip if the given config is already being applied.
|
||||
if (tag == iconRes) return
|
||||
tag = iconRes
|
||||
pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener)
|
||||
|
||||
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
|
||||
if (!isOrWillBeHidden) {
|
||||
logD("Starting hide for flip")
|
||||
flipping = true
|
||||
// We will re-show the FAB later, assuming that there was not a prior flip operation.
|
||||
super.hide(FlipVisibilityListener())
|
||||
} else {
|
||||
logD("Already hiding, will apply config later")
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingConfig(
|
||||
@DrawableRes val iconRes: Int,
|
||||
@StringRes val contentDescriptionRes: Int,
|
||||
val clickListener: OnClickListener
|
||||
)
|
||||
|
||||
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
if (!flipping) return
|
||||
logD("Starting show for flip")
|
||||
flipping = false
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,14 +18,13 @@
|
|||
|
||||
package org.oxycblt.auxio.home
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
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
|
||||
|
|
@ -42,6 +41,7 @@ import com.google.android.material.transition.MaterialSharedAxis
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Field
|
||||
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 +51,29 @@ 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.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
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.lazyReflectedField
|
||||
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
|
||||
|
|
@ -89,23 +90,25 @@ class HomeFragment :
|
|||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||
private var getContentLauncher: ActivityResultLauncher<String>? = null
|
||||
private var pendingImportTarget: Playlist? = null
|
||||
|
||||
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)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
|
|
@ -115,25 +118,18 @@ class HomeFragment :
|
|||
musicModel.refresh()
|
||||
}
|
||||
|
||||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
L.w("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
L.d("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
|
||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||
binding.homeNormalToolbar.apply {
|
||||
setOnMenuItemClickListener(this@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
|
||||
|
|
@ -172,15 +168,22 @@ class HomeFragment :
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collectImmediately(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -209,17 +212,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,7 +243,7 @@ class HomeFragment :
|
|||
true
|
||||
}
|
||||
else -> {
|
||||
L.w("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +257,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 +270,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 +290,24 @@ class HomeFragment :
|
|||
MusicType.GENRES -> R.id.home_genre_recycler
|
||||
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
|
||||
}
|
||||
|
||||
if (tabType != MusicType.PLAYLISTS) {
|
||||
logD("Flipping to shuffle button")
|
||||
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
||||
playbackModel.shuffleAll()
|
||||
}
|
||||
} else {
|
||||
logD("Flipping to playlist button")
|
||||
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,92 +315,123 @@ 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 handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
||||
if (error == null) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.New -> {
|
||||
L.d("Creating new playlist")
|
||||
HomeFragmentDirections.newPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray(),
|
||||
decision.template,
|
||||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Import -> {
|
||||
L.d("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
}
|
||||
.launch(M3U.MIME_TYPE)
|
||||
musicModel.playlistDecision.consume()
|
||||
return
|
||||
logD("Creating new playlist")
|
||||
HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
HomeFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
decision.applySongs.map { it.uid }.toTypedArray(),
|
||||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
logD("Renaming ${decision.playlist}")
|
||||
HomeFragmentDirections.renamePlaylist(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())
|
||||
}
|
||||
|
|
@ -390,61 +439,60 @@ class HomeFragment :
|
|||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||
if (message == null) return
|
||||
requireContext().showToast(message.stringRes)
|
||||
musicModel.playlistMessage.consume()
|
||||
}
|
||||
|
||||
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.playFromArtist(decision.song.uid))
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.playFromGenre(decision.song.uid))
|
||||
}
|
||||
null -> {}
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
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]")
|
||||
binding.homeFab.hide()
|
||||
} else {
|
||||
logD("Showing fab")
|
||||
binding.homeFab.show()
|
||||
}
|
||||
}
|
||||
|
||||
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 +520,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 +528,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 +568,6 @@ 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")
|
||||
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
@ -163,53 +160,64 @@ constructor(
|
|||
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 +225,8 @@ constructor(
|
|||
*/
|
||||
fun applySongSort(sort: Sort) {
|
||||
listSettings.songSort = sort
|
||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -226,6 +236,8 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSort(sort: Sort) {
|
||||
listSettings.albumSort = sort
|
||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -235,6 +247,8 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSort(sort: Sort) {
|
||||
listSettings.artistSort = sort
|
||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -244,6 +258,8 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSort(sort: Sort) {
|
||||
listSettings.genreSort = sort
|
||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -253,6 +269,8 @@ constructor(
|
|||
*/
|
||||
fun applyPlaylistSort(sort: Sort) {
|
||||
listSettings.playlistSort = sort
|
||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -261,7 +279,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,14 +289,10 @@ 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)
|
||||
}
|
||||
|
||||
fun showSettings() {
|
||||
_showOuter.put(Outer.Settings)
|
||||
}
|
||||
|
|
@ -286,6 +300,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 {
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2018 Auxio Project
|
||||
* ThemedSpeedDialView.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 android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.RotateDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.util.Property
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.leinardi.android.speeddial.FabWithLabelView
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
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
|
||||
|
||||
/**
|
||||
* Customized Speed Dial view with some bug fixes and Material 3 theming.
|
||||
*
|
||||
* Adapted from Material Files:
|
||||
* https://github.com/zhanghai/MaterialFiles/tree/79f1727cec72a6a089eb495f79193f87459fc5e3
|
||||
*
|
||||
* MODIFICATIONS:
|
||||
* - Removed dynamic theme changes based on the MaterialFile's Material 3 setting
|
||||
* - Adapted code to the extensions in this project
|
||||
*
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ThemedSpeedDialView : SpeedDialView {
|
||||
private var mainFabAnimator: Animator? = null
|
||||
private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small)
|
||||
private var innerChangeListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
@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
|
||||
mainFab.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
setMargins(context.getDimenPixels(R.dimen.spacing_medium))
|
||||
}
|
||||
useCompatPadding = false
|
||||
}
|
||||
val context = context
|
||||
mainFabClosedBackgroundColor =
|
||||
context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorPrimaryContainer)
|
||||
.defaultColor
|
||||
mainFabClosedIconColor =
|
||||
context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimaryContainer)
|
||||
.defaultColor
|
||||
mainFabOpenedBackgroundColor =
|
||||
context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary).defaultColor
|
||||
mainFabOpenedIconColor =
|
||||
context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimary)
|
||||
.defaultColor
|
||||
|
||||
// Always use our own animation to fix the library issue that ripple is rotated as well.
|
||||
val mainFabDrawable =
|
||||
RotateDrawable().apply {
|
||||
drawable = mainFab.drawable
|
||||
toDegrees = 45f + 90f
|
||||
}
|
||||
mainFabAnimationRotateAngle = 0f
|
||||
setMainFabClosedDrawable(mainFabDrawable)
|
||||
setOnChangeListener(
|
||||
object : OnChangeListener {
|
||||
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 {
|
||||
addListener(
|
||||
object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mainFabAnimator = null
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
innerChangeListener?.invoke(isOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
mainFab,
|
||||
VIEW_PROPERTY_BACKGROUND_TINT,
|
||||
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor)
|
||||
.apply {
|
||||
startDelay = delay
|
||||
duration = partialDuration
|
||||
}
|
||||
|
||||
val imageTintAnimator =
|
||||
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
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
|
||||
val overlayLayout = overlayLayout
|
||||
if (overlayLayout != null) {
|
||||
val surfaceColor =
|
||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||
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(
|
||||
@FloatRange(from = 0.0, to = 1.0) alphaModulation: Float
|
||||
): Int {
|
||||
val alpha = (alpha * alphaModulation).roundToInt()
|
||||
return ((alpha shl 24) or (this and 0x00FFFFFF))
|
||||
}
|
||||
|
||||
override fun addActionItem(
|
||||
actionItem: SpeedDialActionItem,
|
||||
position: Int,
|
||||
animate: Boolean
|
||||
): FabWithLabelView? {
|
||||
val context = context
|
||||
val fabImageTintColor = context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary)
|
||||
val fabBackgroundColor =
|
||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||
val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary)
|
||||
val labelBackgroundColor =
|
||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||
val labelElevation =
|
||||
context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation)
|
||||
val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium)
|
||||
val actionItem =
|
||||
SpeedDialActionItem.Builder(
|
||||
actionItem.id,
|
||||
// Should not be a resource, pass null to fail fast.
|
||||
actionItem.getFabImageDrawable(null))
|
||||
.setLabel(actionItem.getLabel(context))
|
||||
.setFabImageTintColor(fabImageTintColor.defaultColor)
|
||||
.setFabBackgroundColor(fabBackgroundColor.defaultColor)
|
||||
.setLabelColor(labelColor.defaultColor)
|
||||
.setLabelBackgroundColor(labelBackgroundColor.defaultColor)
|
||||
.setLabelClickable(actionItem.isLabelClickable)
|
||||
.setTheme(actionItem.theme)
|
||||
.create()
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = labelBackgroundColor
|
||||
elevation = labelElevation
|
||||
setCornerSize(cornerRadius.toFloat())
|
||||
}
|
||||
foreground = null
|
||||
(getChildAt(0) as TextView).apply {
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_LabelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val superState =
|
||||
BundleCompat.getParcelable(
|
||||
super.onSaveInstanceState() as Bundle, "superState", Parcelable::class.java)
|
||||
return State(superState, isOpen)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable) {
|
||||
state as State
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
if (state.isOpen) {
|
||||
toggle(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setChangeListener(listener: ((Boolean) -> Unit)?) {
|
||||
innerChangeListener = listener
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VIEW_PROPERTY_BACKGROUND_TINT =
|
||||
object : Property<View, Int>(Int::class.java, "backgroundTint") {
|
||||
override fun get(view: View): Int = view.backgroundTintList!!.defaultColor
|
||||
|
||||
override fun set(view: View, value: Int?) {
|
||||
view.backgroundTintList = ColorStateList.valueOf(value!!)
|
||||
}
|
||||
}
|
||||
|
||||
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
|
||||
object : Property<ImageView, Int>(Int::class.java, "imageTint") {
|
||||
override fun get(view: ImageView): Int = view.imageTintList!!.defaultColor
|
||||
|
||||
override fun set(view: ImageView, value: Int?) {
|
||||
view.imageTintList = ColorStateList.valueOf(value!!)
|
||||
}
|
||||
}
|
||||
|
||||
private val DRAWABLE_PROPERTY_LEVEL =
|
||||
object : Property<Drawable, Int>(Int::class.java, "level") {
|
||||
override fun get(drawable: Drawable): Int = drawable.level
|
||||
|
||||
override fun set(drawable: Drawable, value: Int?) {
|
||||
drawable.level = value!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -107,14 +95,14 @@ class AlbumListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val album = homeModel.albumList.value.getOrNull(pos) ?: return null
|
||||
val album = homeModel.albumList.value[pos]
|
||||
// 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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -102,11 +90,11 @@ class ArtistListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val artist = homeModel.artistList.value.getOrNull(pos) ?: return null
|
||||
val artist = homeModel.artistList.value[pos]
|
||||
// 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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -101,11 +89,11 @@ class GenreListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val genre = homeModel.genreList.value.getOrNull(pos) ?: return null
|
||||
val genre = homeModel.genreList.value[pos]
|
||||
// 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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> "?"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -101,11 +87,11 @@ class PlaylistListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val playlist = homeModel.playlistList.value.getOrNull(pos) ?: return null
|
||||
val playlist = homeModel.playlistList.value[pos]
|
||||
// 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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -105,29 +92,29 @@ class SongListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val song = homeModel.songList.value.getOrNull(pos) ?: return null
|
||||
val song = homeModel.songList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
// Note: We don't use the more correct individual artist name here, as sorts are largely
|
||||
// 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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL))
|
||||
.target(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,35 @@ 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.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
|
||||
|
|
@ -77,7 +73,7 @@ import org.oxycblt.musikr.covers.CoverCollection
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
open class CoverView
|
||||
class CoverView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
|
@ -95,41 +91,32 @@ 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
|
||||
private data class Cover(
|
||||
val songs: Collection<Song>,
|
||||
val desc: String,
|
||||
@DrawableRes val errorRes: Int
|
||||
)
|
||||
|
||||
private var currentCover: Cover? = null
|
||||
|
||||
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 +160,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 +190,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 +254,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 +271,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 +297,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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -316,8 +342,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param song The [Song] to bind to the view.
|
||||
*/
|
||||
fun bind(song: Song) =
|
||||
bindImpl(
|
||||
song.cover,
|
||||
bind(
|
||||
listOf(song),
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
|
@ -327,8 +353,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param album The [Album] to bind to the view.
|
||||
*/
|
||||
fun bind(album: Album) =
|
||||
bindImpl(
|
||||
album.covers,
|
||||
bind(
|
||||
album.songs,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
|
@ -338,8 +364,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param artist The [Artist] to bind to the view.
|
||||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bindImpl(
|
||||
artist.covers,
|
||||
bind(
|
||||
artist.songs,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
|
@ -349,8 +375,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param genre The [Genre] to bind to the view.
|
||||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bindImpl(
|
||||
genre.covers,
|
||||
bind(
|
||||
genre.songs,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
|
@ -360,8 +386,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param playlist the [Playlist] to bind.
|
||||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bindImpl(
|
||||
playlist.covers,
|
||||
bind(
|
||||
playlist.songs,
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
|
||||
|
|
@ -372,22 +398,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param desc The content description to describe the bound data.
|
||||
* @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)
|
||||
|
||||
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
|
||||
fun bind(songs: Collection<Song>, desc: String, @DrawableRes errorRes: Int) {
|
||||
val request =
|
||||
ImageRequest.Builder(context)
|
||||
.data(cover)
|
||||
.error(
|
||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||
.asImage())
|
||||
.data(songs)
|
||||
.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 {
|
||||
|
|
@ -398,6 +417,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
CoilUtils.dispose(image)
|
||||
imageLoader.enqueue(request.build())
|
||||
contentDescription = desc
|
||||
currentCover = Cover(songs, desc, errorRes)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -407,7 +427,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 +435,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 +457,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Keyer<Collection<Song>> {
|
||||
override fun key(data: Collection<Song>, options: Options) =
|
||||
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
|
||||
}
|
||||
|
||||
class SongCoverFetcher
|
||||
private constructor(
|
||||
private val songs: Collection<Song>,
|
||||
private val size: Size,
|
||||
private val coverExtractor: CoverExtractor,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
||||
|
||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Fetcher.Factory<Collection<Song>> {
|
||||
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) =
|
||||
SongCoverFetcher(data, options.size, coverExtractor)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
/*
|
||||
* 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.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.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
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 songs The [Song]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(songs: Collection<Song>, size: Size): FetchResult? {
|
||||
val albums = computeCoverOrdering(songs)
|
||||
val streams = mutableListOf<InputStream>()
|
||||
for (album in albums) {
|
||||
openCoverInputStream(album)?.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an [Album] list representing the order that album covers would be used in [extract].
|
||||
*
|
||||
* @param songs A hypothetical list of [Song]s that would be used in [extract].
|
||||
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
|
||||
* by their names. "Representation" is defined by how many [Song]s were found to be linked to
|
||||
* the given [Album] in the given [Song] list.
|
||||
*/
|
||||
fun computeCoverOrdering(songs: Collection<Song>): List<Album> {
|
||||
// TODO: Start short-circuiting in more places
|
||||
if (songs.isEmpty()) return listOf()
|
||||
if (songs.size == 1) return listOf(songs.first().album)
|
||||
|
||||
val sortedMap =
|
||||
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
|
||||
for (song in songs) {
|
||||
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
|
||||
}
|
||||
return sortedMap.keys.sortedByDescending { sortedMap[it] }
|
||||
}
|
||||
|
||||
private suspend fun openCoverInputStream(album: Album) =
|
||||
try {
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> null
|
||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
|
||||
CoverMode.QUALITY -> extractQualityCover(album)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to extract album cover due to an error: $e")
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun extractQualityCover(album: Album) =
|
||||
extractAospMetadataCover(album)
|
||||
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
|
||||
|
||||
private fun extractAospMetadataCover(album: Album): 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, album.coverUri.song)
|
||||
|
||||
// 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(album: Album): InputStream? {
|
||||
val tracks =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
|
||||
.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
|
||||
}
|
||||
|
||||
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 extractMediaStoreCover(album: Album) =
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(album.coverUri.mediaStore)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* ReplayGainPreAmp.kt is part of Auxio.
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CoverUri.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,13 +16,17 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.replaygain
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* The current ReplayGain pre-amp configuration.
|
||||
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
|
||||
* images.
|
||||
*
|
||||
* @param with The pre-amp (in dB) to use when ReplayGain tags are present.
|
||||
* @param without The pre-amp (in dB) to use when ReplayGain tags are not present.
|
||||
* @param mediaStore The album cover [Uri] obtained from MediaStore.
|
||||
* @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
|
||||
* an album cover.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class ReplayGainPreAmp(val with: Float, val without: Float)
|
||||
data class CoverUri(val mediaStore: Uri, val song: Uri)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
songKeyer: SongKeyer,
|
||||
songFactory: SongCoverFetcher.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(songKeyer)
|
||||
add(songFactory)
|
||||
}
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -179,11 +178,7 @@ class ArtistMenuDialogFragment : MenuDialogFragment<Menu.ForArtist>() {
|
|||
binding.menuInfo.text =
|
||||
getString(
|
||||
R.string.fmt_two,
|
||||
if (menu.artist.explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
context.getPlural(R.plurals.fmt_album_count, menu.artist.albums.size),
|
||||
if (menu.artist.songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size)
|
||||
} else {
|
||||
|
|
@ -289,7 +284,6 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
|
|||
R.id.action_play_next,
|
||||
R.id.action_queue_add,
|
||||
R.id.action_playlist_add,
|
||||
R.id.action_export,
|
||||
R.id.action_share)
|
||||
} else {
|
||||
setOf()
|
||||
|
|
@ -322,8 +316,6 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
|
|||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
|
||||
R.id.action_import -> musicModel.importPlaylist(target = menu.playlist)
|
||||
R.id.action_export -> musicModel.exportPlaylist(menu.playlist)
|
||||
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
|
||||
R.id.action_share -> requireContext().share(menu.playlist)
|
||||
else -> error("Unexpected menu item $item")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,16 +25,11 @@ 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
|
||||
import kotlin.math.sign
|
||||
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,
|
||||
|
|
@ -58,27 +53,6 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
0
|
||||
}
|
||||
|
||||
override fun interpolateOutOfBoundsScroll(
|
||||
recyclerView: RecyclerView,
|
||||
viewSize: Int,
|
||||
viewSizeOutOfBounds: Int,
|
||||
totalSize: Int,
|
||||
msSinceStartScroll: Long
|
||||
): Int {
|
||||
// Clamp the scroll speed to prevent the lists from freaking out
|
||||
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
|
||||
val standardSpeed =
|
||||
super.interpolateOutOfBoundsScroll(
|
||||
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
|
||||
|
||||
val clampedAbsVelocity =
|
||||
max(
|
||||
MINIMUM_INITIAL_DRAG_VELOCITY,
|
||||
min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
|
||||
|
||||
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
|
||||
}
|
||||
|
||||
final override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
|
|
@ -92,11 +66,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 +110,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)
|
||||
|
|
@ -175,9 +150,4 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
/** The drawable of the [body] background that can be elevated. */
|
||||
val background: Drawable
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
|
||||
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -166,11 +164,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
binding.parentInfo.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)
|
||||
},
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||
} else {
|
||||
|
|
@ -205,7 +199,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
object : SimpleDiffCallback<Artist>() {
|
||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||
oldItem.name == newItem.name &&
|
||||
oldItem.explicitAlbums.size == newItem.explicitAlbums.size &&
|
||||
oldItem.albums.size == newItem.albums.size &&
|
||||
oldItem.songs.size == newItem.songs.size
|
||||
}
|
||||
}
|
||||
|
|
@ -362,7 +356,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 +377,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue