Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f8bdc618 | ||
|
|
903fde4bdc | ||
|
|
3824dd882c | ||
|
|
602bab6414 | ||
|
|
d891e429b6 | ||
|
|
ebefd77027 | ||
|
|
57f34affd9 | ||
|
|
50b5ab38bc | ||
|
|
9f61d70fca | ||
|
|
a97a2d5b50 | ||
|
|
8d73a2cd36 | ||
|
|
ca5a0698bb | ||
|
|
04f34e03d1 | ||
|
|
233bc9987e | ||
|
|
f0e418687e | ||
|
|
e87b658447 | ||
|
|
19c985c9e4 | ||
|
|
4ab122a9d7 | ||
|
|
ff0c42d14c | ||
|
|
d1e247f9e2 | ||
|
|
f1d19142fa | ||
|
|
45793c343a | ||
|
|
aa4249842d | ||
|
|
126663f1e5 | ||
|
|
ec19e8c401 | ||
|
|
717f95a04a | ||
|
|
ccce01a61b | ||
|
|
a7682d7656 | ||
|
|
84de93a4f1 | ||
|
|
78c4c89eca | ||
|
|
328beaff90 | ||
|
|
9d5d89d648 | ||
|
|
cd8b06f544 | ||
|
|
47a0def06c | ||
|
|
1c2f1aa061 | ||
|
|
30281e8f2d | ||
|
|
3c58e6fbb2 | ||
|
|
99a399b4d7 | ||
|
|
1da0a0b810 | ||
|
|
539920965e | ||
|
|
9a64eeabe6 | ||
|
|
791190f681 | ||
|
|
c03fca8039 | ||
|
|
620fba0a14 | ||
|
|
1357c5c062 | ||
|
|
682f63ef38 | ||
|
|
24864637f9 | ||
|
|
3ba19be4d9 | ||
|
|
cce6456951 | ||
|
|
fda586c4d8 | ||
|
|
57be72d5d4 | ||
|
|
c2354d4d42 | ||
|
|
a940af934c | ||
|
|
5891ec800c | ||
|
|
7259a82b67 | ||
|
|
c2b6d7eed5 | ||
|
|
2335bf2095 | ||
|
|
8bb6c02e46 | ||
|
|
47380a79a5 | ||
|
|
a187ba1e75 | ||
|
|
3eb9b2fb5c | ||
|
|
a547e19361 | ||
|
|
f4722fa0a8 | ||
|
|
ee738bc4c7 | ||
|
|
a22883fdde | ||
|
|
2acf11023a | ||
|
|
9736890e3c | ||
|
|
e790bf3eb6 | ||
|
|
e1d63a9eef | ||
|
|
134a1605ad | ||
|
|
1b45036963 | ||
|
|
0ba12c3d84 | ||
|
|
8cc3356b14 | ||
|
|
9d439b726b | ||
|
|
d4c0e30fd1 | ||
|
|
bb23d7e866 | ||
|
|
7321ef46f2 | ||
|
|
2fe2c2b28b | ||
|
|
a9318ec5d0 | ||
|
|
eb29dc2fb2 | ||
|
|
287e4a2b10 | ||
|
|
b7d56c2d70 | ||
|
|
5a6d101bdf | ||
|
|
969f0b5b21 | ||
|
|
14939d20fd | ||
|
|
35af1f9038 | ||
|
|
b79cfa4af0 | ||
|
|
e81e1a5356 | ||
|
|
cc0e264a17 | ||
|
|
a83495f353 | ||
|
|
be4346b3d1 | ||
|
|
2e29e9537a | ||
|
|
bc0adfe8e0 | ||
|
|
5261ca317b | ||
|
|
bf4ff3f1f9 | ||
|
|
cd195dbba0 | ||
|
|
7ec78991a5 | ||
|
|
e1c5a60805 | ||
|
|
f74813ef69 | ||
|
|
040558198e | ||
|
|
5ab68e4a98 | ||
|
|
aa8fac43a6 | ||
|
|
905bb3e3c5 | ||
|
|
d810010090 | ||
|
|
52ba783a90 | ||
|
|
d72855e160 | ||
|
|
82a9f00173 | ||
|
|
3f5749f7e1 | ||
|
|
cd2ab36351 | ||
|
|
a6688f897a | ||
|
|
64658dda1f | ||
|
|
d9f701d9d3 | ||
|
|
60fee3c77c | ||
|
|
b89086c5be | ||
|
|
d3dd236054 | ||
|
|
2e3330b63f | ||
|
|
e604c9ba86 | ||
|
|
2bf39d846e | ||
|
|
06066f1f66 | ||
|
|
7c0d44680f | ||
|
|
d4cb6c5c9a | ||
|
|
fab18c130e | ||
|
|
bd753f4489 | ||
|
|
e43a2b6fe5 | ||
|
|
c62d2ace4d | ||
|
|
6d403f808c | ||
|
|
1223062388 | ||
|
|
b0ddd5388b | ||
|
|
92f79a8e3d | ||
|
|
473d7e4e9c | ||
|
|
7ca0415274 | ||
|
|
cf7feacdc0 | ||
|
|
4740028a44 | ||
|
|
fe2c163aaa | ||
|
|
c6f08d9cec | ||
|
|
59b40df9ef | ||
|
|
81726baa08 | ||
|
|
af92a7b11a | ||
|
|
0578745bee | ||
|
|
e24063e460 | ||
|
|
4f1b1b603e | ||
|
|
7279c62944 | ||
|
|
a59d46f884 | ||
|
|
10285b308d | ||
|
|
8e2c5d1fee | ||
|
|
f59a360eb7 | ||
|
|
accf5fddc2 | ||
|
|
cc61d1cd48 |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: eddyizm
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
62
.github/workflows/github_release.yml
vendored
62
.github/workflows/github_release.yml
vendored
@@ -35,12 +35,18 @@ jobs:
|
||||
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- name: Build APK
|
||||
- name: Build All APKs
|
||||
id: build
|
||||
run: bash ./gradlew assembleTempoRelease
|
||||
run: |
|
||||
# Build release variants
|
||||
bash ./gradlew assembleTempoRelease
|
||||
bash ./gradlew assembleNotquitemyRelease
|
||||
# Build debug variants
|
||||
bash ./gradlew assembleTempoDebug
|
||||
bash ./gradlew assembleNotquitemyDebug
|
||||
|
||||
- name: Sign APK
|
||||
id: sign_apk
|
||||
- name: Sign Tempo Release APKs
|
||||
id: sign_tempo_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/tempo/release
|
||||
@@ -51,11 +57,17 @@ jobs:
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Make artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Sign NotQuiteMy Release APKs
|
||||
id: sign_notquitemy_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
name: app-release-signed
|
||||
path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
releaseDirectory: app/build/outputs/apk/notquitemy/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -67,12 +79,40 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Upload APK
|
||||
- name: Upload Release APKs
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
asset_path: ${{steps.sign_tempo_release.outputs.signedReleaseFile}}
|
||||
asset_name: app-tempo-release.apk
|
||||
asset_content_type: application/zip
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
|
||||
- name: Upload NotQuiteMy Release APK
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
|
||||
asset_name: app-notquitemy-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
|
||||
- name: Upload Debug APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apks
|
||||
path: |
|
||||
app/build/outputs/apk/tempo/debug/
|
||||
app/build/outputs/apk/notquitemy/debug/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Release APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-apks
|
||||
path: |
|
||||
${{steps.sign_tempo_release.outputs.signedReleaseFile}}
|
||||
${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
|
||||
retention-days: 30
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -2,6 +2,75 @@
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
|
||||
## [3.16.0](https://github.com/eddyizm/tempo/releases/tag/v3.16.0) (2025-10-07)
|
||||
## What's Changed
|
||||
* chore: add sha256 fingerprint for validation by @eddyizm in https://github.com/eddyizm/tempo/commit/3c58e6fbb2157a804853259dfadbbffe3b6793b5
|
||||
* fix: Prevent crash when getting artist radio and song list is null by @jaime-grj in https://github.com/eddyizm/tempo/pull/117
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/125
|
||||
* fix: Update search query validation to require at least 2 characters instead of 3 by @jaime-grj in https://github.com/eddyizm/tempo/pull/124
|
||||
* feat: download starred artists. by @eddyizm in https://github.com/eddyizm/tempo/pull/137
|
||||
* feat: Enable downloading of song lyrics for offline viewing by @le-firehawk in https://github.com/eddyizm/tempo/pull/99
|
||||
* fix: Lag during startup when local url is not available by @SinTan1729 in https://github.com/eddyizm/tempo/pull/110
|
||||
* chore: add link to discussion page in settings by @eddyizm in https://github.com/eddyizm/tempo/pull/143
|
||||
* feat: Notification heart rating by @eddyizm in https://github.com/eddyizm/tempo/pull/140
|
||||
* chore: Unify and update polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/146
|
||||
* chore: added sha256 signing key for verification by @eddyizm in https://github.com/eddyizm/tempo/pull/147
|
||||
* feat: Support user-defined download directory for media by @le-firehawk in https://github.com/eddyizm/tempo/pull/21
|
||||
* feat: Added support for skipping duplicates by @SinTan1729 in https://github.com/eddyizm/tempo/pull/135
|
||||
* feat: Add home screen music playback widget and some updates in Turkish localization by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/98
|
||||
|
||||
## New Contributors
|
||||
* @SinTan1729 made their first contribution in https://github.com/eddyizm/tempo/pull/110
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.15.0...v3.16.0
|
||||
|
||||
## [3.15.0](https://github.com/eddyizm/tempo/releases/tag/v3.15.0) (2025-09-23)
|
||||
## What's Changed
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/84
|
||||
* chore: Update RU locale by @ArchiDevil in https://github.com/eddyizm/tempo/pull/87
|
||||
* chore: Update Korean translations by @kongwoojin in https://github.com/eddyizm/tempo/pull/97
|
||||
* fix: only plays the first song on an album by @eddyizm in https://github.com/eddyizm/tempo/pull/81
|
||||
* fix: handle null and not crash when disconnecting chromecast by @eddyizm in https://github.com/eddyizm/tempo/pull/81
|
||||
* feat: Built-in audio equalizer by @jaime-grj in https://github.com/eddyizm/tempo/pull/94
|
||||
* fix: Resolve playback issues with live radio MPEG & HLS streams by @jaime-grj in https://github.com/eddyizm/tempo/pull/89
|
||||
* chore: Updates to polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/105
|
||||
* feat: added 32bit build and debug build for testing. Removed unused f… by @eddyizm in https://github.com/eddyizm/tempo/pull/108
|
||||
* feat: Mark currently playing song with play/pause button by @jaime-grj in https://github.com/eddyizm/tempo/pull/107
|
||||
* fix: add listener to track playlist click/change by @eddyizm in https://github.com/eddyizm/tempo/pull/113
|
||||
* feat: Tap anywhere on the song item to toggle playback by @jaime-grj in https://github.com/eddyizm/tempo/pull/112
|
||||
|
||||
## New Contributors
|
||||
* @ArchiDevil made their first contribution in https://github.com/eddyizm/tempo/pull/87
|
||||
* @kongwoojin made their first contribution in https://github.com/eddyizm/tempo/pull/97
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.8...v3.15.0
|
||||
|
||||
|
||||
## [3.14.8](https://github.com/eddyizm/tempo/releases/tag/v3.14.8) (2025-08-30)
|
||||
## What's Changed
|
||||
* fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/76
|
||||
* chore(i18n): Update Spanish (es-ES) and English translations by @jaime-grj in https://github.com/eddyizm/tempo/pull/77
|
||||
* style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line by @jaime-grj in https://github.com/eddyizm/tempo/pull/78
|
||||
* fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog by @jaime-grj in https://github.com/eddyizm/tempo/pull/79
|
||||
* bug fixes, chores, docs v3.14.8 by @eddyizm in https://github.com/eddyizm/tempo/pull/80
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.1...v3.14.8
|
||||
|
||||
## [3.14.1](https://github.com/eddyizm/tempo/releases/tag/v3.14.1) (2025-08-30)
|
||||
## What's Changed
|
||||
* feat: rating dialog added to album page by @eddyizm in https://github.com/eddyizm/tempo/pull/52
|
||||
* style: Add song rating bar in landscape player controller layout by @jaime-grj in https://github.com/eddyizm/tempo/pull/57
|
||||
* feat: setting to show/hide 5 star rating on playerview by @eddyizm in https://github.com/eddyizm/tempo/pull/59
|
||||
* chore: setting-to-hide-song-rating by @eddyizm in https://github.com/eddyizm/tempo/pull/60
|
||||
* fix: catches null value and prepares bundle appropriately adding sing… by @eddyizm in https://github.com/eddyizm/tempo/pull/64
|
||||
* fix: artist filtering in library view browse artist resolves #45 by @eddyizm in https://github.com/eddyizm/tempo/pull/69
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/70
|
||||
* feat: adds sync starred albums functionality #66 by @eddyizm in https://github.com/eddyizm/tempo/pull/73
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.13.0...v3.14.1
|
||||
|
||||
## [3.13.0](https://github.com/eddyizm/tempo/releases/tag/v3.13.0) (2025-08-23)
|
||||
## What's Changed
|
||||
* style: Change position and size of rating container by @jaime-grj in https://github.com/eddyizm/tempo/pull/44
|
||||
|
||||
@@ -24,12 +24,19 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
|
||||
|
||||
## Fork
|
||||
|
||||
sha256 signing key fingerprint
|
||||
`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
|
||||
|
||||
This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see.
|
||||
|
||||
Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
|
||||
|
||||
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
|
||||
|
||||
## Usage
|
||||
|
||||
[Documentation](USAGE.md) (work in progress)
|
||||
|
||||
## Features
|
||||
- **Subsonic Integration**: Tempo seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
|
||||
- **Sleek and Intuitive UI**: Enjoy a clean and user-friendly interface designed to enhance your music listening experience, tailored to your preferences and listening history.
|
||||
|
||||
146
USAGE.md
Normal file
146
USAGE.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Tempo Usage Guide
|
||||
[<- back home](README.md)
|
||||
|
||||
## Table of Contents
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Server Configuration](#server-configuration)
|
||||
- [Main Features](#main-features)
|
||||
- [Navigation](#navigation)
|
||||
- [Playback Controls](#playback-controls)
|
||||
- [Favorites](#favorites)
|
||||
- [Playlist Management](#playlist-management)
|
||||
- [Android Auto](#android-auto)
|
||||
- [Settings](#settings)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Important Notice**: This app is a Subsonic-compatible client and does not provide any music content itself. To use this application, you must have:
|
||||
|
||||
- An active Subsonic API server (or compatible service) already set up
|
||||
- Valid login credentials for your Subsonic server
|
||||
- Music content uploaded and organized on your server
|
||||
|
||||
### Verified backends
|
||||
This app works with any service that implements the Subsonic API, including:
|
||||
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
|
||||
- [Navidrome](https://www.navidrome.org/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
|
||||
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
1. Download the APK from the [Releases](https://github.com/eddyizm/tempo/releases) section
|
||||
2. Enable "Install from unknown sources" in your Android settings
|
||||
3. Install the application
|
||||
|
||||
### First Launch
|
||||
1. Open the application
|
||||
2. You will be prompted to configure your server connection
|
||||
3. Grant necessary permissions for media playback and background operation
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### Initial Setup
|
||||
**IN PROGRESS**
|
||||
1. Enter your server URL (e.g., `https://your-subsonic-server.com`)
|
||||
2. Provide your username and password
|
||||
3. Test the connection to ensure proper configuration
|
||||
|
||||
### Advanced Settings
|
||||
**TODO**
|
||||
|
||||
## Main Features
|
||||
|
||||
### Library View
|
||||
**TODO**
|
||||
|
||||
### Now Playing Screen
|
||||
**TODO**
|
||||
|
||||
## Navigation
|
||||
|
||||
### Bottom Navigation Bar
|
||||
**IN PROGRESS**
|
||||
- **Home**: Recently played and server recommendations
|
||||
- **Library**: Your server's complete music collection
|
||||
- **Download**: Locally downloaded files from server
|
||||
|
||||
## Playback Controls
|
||||
|
||||
### Streaming Controls
|
||||
**TODO**
|
||||
|
||||
### Advanced Controls
|
||||
**TODO**
|
||||
|
||||
## Favorites
|
||||
|
||||
### Favorites (aka heart aka star) to albums and artists
|
||||
- Long pressing on an album gives you access to heart/unheart an album
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/usage/fave_album.png" width=376>
|
||||
</p>
|
||||
|
||||
- Long pressing on an artist cover gets you the same access to to heart/unheart an album
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/usage/fave_artist.png" width=376>
|
||||
</p>
|
||||
|
||||
|
||||
## Playlist Management
|
||||
|
||||
### Server Playlists
|
||||
**TODO**
|
||||
|
||||
### Creating Playlists
|
||||
**TODO**
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
## Android Auto
|
||||
|
||||
### Enabling on your head unit
|
||||
- You have to enable Android Auto developer options, which are different from actual Android dev options. Then you have to enable "Unknown sources" in Android Auto, otherwise the app won't appear as it isn't downloaded from Play Store. (screenshots needed)
|
||||
|
||||
|
||||
### Server Settings
|
||||
**IN PROGRESS**
|
||||
- Manage multiple server connections
|
||||
- Configure sync intervals
|
||||
- Set data usage limits for streaming
|
||||
|
||||
### Audio Settings
|
||||
**IN PROGRESS**
|
||||
- Streaming quality settings
|
||||
- Offline caching preferences
|
||||
|
||||
### Appearance
|
||||
**TODO**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
**TODO**
|
||||
|
||||
### Common Issues
|
||||
|
||||
**TODO**
|
||||
|
||||
### Support
|
||||
For additional help:
|
||||
- Question? Start a [Discussion](https://github.com/eddyizm/tempo/discussions)
|
||||
- Open an [issue](https://github.com/eddyizm/tempo/issues) if you don't find a discussion solving your issue.
|
||||
- Consult your Subsonic server's documentation
|
||||
|
||||
---
|
||||
|
||||
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*
|
||||
@@ -10,9 +10,8 @@ android {
|
||||
minSdkVersion 24
|
||||
targetSdk 35
|
||||
|
||||
versionCode 30
|
||||
versionName '3.14.1'
|
||||
|
||||
versionCode 34
|
||||
versionName '3.16.6'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
javaCompileOptions {
|
||||
@@ -23,8 +22,21 @@ android {
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
//noinspection ChromeOsAbiSupport
|
||||
include 'armeabi-v7a', 'arm64-v8a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
flavorDimensions += "default"
|
||||
|
||||
productFlavors {
|
||||
@@ -38,10 +50,6 @@ android {
|
||||
applicationId "com.cappielloantonio.notquitemy.tempo"
|
||||
}
|
||||
|
||||
play {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.play.tempo"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -51,6 +59,11 @@ android {
|
||||
debuggable false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -98,7 +111,7 @@ dependencies {
|
||||
implementation 'androidx.media3:media3-ui:1.5.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.5.1'
|
||||
tempoImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
playImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
|
||||
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
annotationProcessor 'androidx.room:room-compiler:2.6.1'
|
||||
@@ -112,4 +125,4 @@ java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -73,5 +73,20 @@
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.WidgetProvider4x1"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info"/>
|
||||
</receiver>
|
||||
|
||||
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters;
|
||||
import com.cappielloantonio.tempo.database.dao.ChronologyDao;
|
||||
import com.cappielloantonio.tempo.database.dao.DownloadDao;
|
||||
import com.cappielloantonio.tempo.database.dao.FavoriteDao;
|
||||
import com.cappielloantonio.tempo.database.dao.LyricsDao;
|
||||
import com.cappielloantonio.tempo.database.dao.PlaylistDao;
|
||||
import com.cappielloantonio.tempo.database.dao.QueueDao;
|
||||
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
|
||||
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao;
|
||||
import com.cappielloantonio.tempo.model.Chronology;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.model.Favorite;
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
import com.cappielloantonio.tempo.model.Queue;
|
||||
import com.cappielloantonio.tempo.model.RecentSearch;
|
||||
import com.cappielloantonio.tempo.model.Server;
|
||||
@@ -28,9 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
|
||||
@UnstableApi
|
||||
@Database(
|
||||
version = 11,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class},
|
||||
autoMigrations = {@AutoMigration(from = 10, to = 11)}
|
||||
version = 12,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
|
||||
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
|
||||
)
|
||||
@TypeConverters({DateConverters.class})
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
@@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||
public abstract SessionMediaItemDao sessionMediaItemDao();
|
||||
|
||||
public abstract PlaylistDao playlistDao();
|
||||
|
||||
public abstract LyricsDao lyricsDao();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ public interface DownloadDao {
|
||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
LiveData<List<Download>> getAll();
|
||||
|
||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
List<Download> getAllSync();
|
||||
|
||||
@Query("SELECT * FROM download WHERE id = :id")
|
||||
Download getOne(String id);
|
||||
|
||||
@@ -30,6 +33,9 @@ public interface DownloadDao {
|
||||
@Query("DELETE FROM download WHERE id = :id")
|
||||
void delete(String id);
|
||||
|
||||
@Query("DELETE FROM download WHERE id IN (:ids)")
|
||||
void deleteByIds(List<String> ids);
|
||||
|
||||
@Query("DELETE FROM download")
|
||||
void deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cappielloantonio.tempo.database.dao;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
|
||||
@Dao
|
||||
public interface LyricsDao {
|
||||
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
|
||||
LyricsCache getOne(String songId);
|
||||
|
||||
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
|
||||
LiveData<LyricsCache> observeOne(String songId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(LyricsCache lyricsCache);
|
||||
|
||||
@Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
|
||||
void delete(String songId);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cappielloantonio.tempo.glide;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.Log;
|
||||
@@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
@@ -109,6 +111,18 @@ public class CustomGlideRequest {
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
public static void loadAlbumArtBitmap(Context context,
|
||||
String coverId,
|
||||
int size,
|
||||
CustomTarget<Bitmap> target) {
|
||||
String url = createUrl(coverId, size);
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(url)
|
||||
.apply(createRequestOptions(context, coverId, ResourceType.Album))
|
||||
.into(target);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private final RequestManager requestManager;
|
||||
private Object item;
|
||||
|
||||
@@ -40,6 +40,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
transcodedSuffix = child.transcodedSuffix
|
||||
duration = child.duration
|
||||
bitrate = child.bitrate
|
||||
samplingRate = child.samplingRate
|
||||
bitDepth = child.bitDepth
|
||||
path = child.path
|
||||
isVideo = child.isVideo
|
||||
userRating = child.userRating
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cappielloantonio.tempo.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlin.jvm.JvmOverloads
|
||||
|
||||
@Keep
|
||||
@Entity(tableName = "lyrics_cache")
|
||||
data class LyricsCache @JvmOverloads constructor(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "song_id")
|
||||
var songId: String,
|
||||
@ColumnInfo(name = "artist")
|
||||
var artist: String? = null,
|
||||
@ColumnInfo(name = "title")
|
||||
var title: String? = null,
|
||||
@ColumnInfo(name = "lyrics")
|
||||
var lyrics: String? = null,
|
||||
@ColumnInfo(name = "structured_lyrics")
|
||||
var structuredLyrics: String? = null,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.model
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.Keep
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.MediaMetadata
|
||||
@@ -243,6 +244,13 @@ class SessionMediaItem() {
|
||||
.setAlbumTitle(album)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artworkUri)
|
||||
.setUserRating(HeartRating(starred != null))
|
||||
.setSupportedCommands(
|
||||
listOf(
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
|
||||
)
|
||||
)
|
||||
.setExtras(bundle)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import android.util.Log;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
|
||||
@@ -13,12 +15,92 @@ import com.cappielloantonio.tempo.subsonic.models.IndexID3;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class ArtistRepository {
|
||||
private final AlbumRepository albumRepository;
|
||||
|
||||
public ArtistRepository() {
|
||||
this.albumRepository = new AlbumRepository();
|
||||
}
|
||||
|
||||
public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) {
|
||||
Log.d("ArtistSync", "Getting albums for artist: " + artistId);
|
||||
|
||||
// Get the artist info first, which contains the albums
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getArtist(artistId)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getArtist() != null &&
|
||||
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
|
||||
|
||||
List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
|
||||
Log.d("ArtistSync", "Got albums directly: " + albums.size());
|
||||
|
||||
if (!albums.isEmpty()) {
|
||||
fetchAllAlbumSongsWithCallback(albums, callback);
|
||||
} else {
|
||||
Log.d("ArtistSync", "No albums found in artist response");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
} else {
|
||||
Log.d("ArtistSync", "Failed to get artist info");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchAllAlbumSongsWithCallback(List<AlbumID3> albums, ArtistSongsCallback callback) {
|
||||
if (albums == null || albums.isEmpty()) {
|
||||
Log.d("ArtistSync", "No albums to process");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
AtomicInteger remainingAlbums = new AtomicInteger(albums.size());
|
||||
Log.d("ArtistSync", "Processing " + albums.size() + " albums");
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
Log.d("ArtistSync", "Getting tracks for album: " + album.getName());
|
||||
MutableLiveData<List<Child>> albumTracks = albumRepository.getAlbumTracks(album.getId());
|
||||
albumTracks.observeForever(songs -> {
|
||||
Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album");
|
||||
if (songs != null) {
|
||||
allSongs.addAll(songs);
|
||||
}
|
||||
albumTracks.removeObservers(null);
|
||||
|
||||
int remaining = remainingAlbums.decrementAndGet();
|
||||
Log.d("ArtistSync", "Remaining albums: " + remaining);
|
||||
|
||||
if (remaining == 0) {
|
||||
Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size());
|
||||
callback.onSongsCollected(allSongs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public interface ArtistSongsCallback {
|
||||
void onSongsCollected(List<Child> songs);
|
||||
}
|
||||
|
||||
public MutableLiveData<List<ArtistID3>> getStarredArtists(boolean random, int size) {
|
||||
MutableLiveData<List<ArtistID3>> starredArtists = new MutableLiveData<>(new ArrayList<>());
|
||||
|
||||
@@ -89,7 +171,7 @@ public class ArtistRepository {
|
||||
}
|
||||
|
||||
/*
|
||||
* Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...)
|
||||
* Method that returns essential artist information (cover, album number, etc.)
|
||||
*/
|
||||
public void getArtistInfo(List<ArtistID3> artists, MutableLiveData<List<ArtistID3>> list) {
|
||||
List<ArtistID3> liveArtists = list.getValue();
|
||||
|
||||
@@ -18,6 +18,20 @@ public class DownloadRepository {
|
||||
return downloadDao.getAll();
|
||||
}
|
||||
|
||||
public List<Download> getAllDownloads() {
|
||||
GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
|
||||
Thread thread = new Thread(getDownloads);
|
||||
thread.start();
|
||||
|
||||
try {
|
||||
thread.join();
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return getDownloads.getDownloads();
|
||||
}
|
||||
|
||||
public Download getDownload(String id) {
|
||||
Download download = null;
|
||||
|
||||
@@ -35,6 +49,24 @@ public class DownloadRepository {
|
||||
return download;
|
||||
}
|
||||
|
||||
private static class GetAllDownloadsThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private List<Download> downloads;
|
||||
|
||||
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
|
||||
this.downloadDao = downloadDao;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloads = downloadDao.getAllSync();
|
||||
}
|
||||
|
||||
public List<Download> getDownloads() {
|
||||
return downloads;
|
||||
}
|
||||
}
|
||||
|
||||
private static class GetDownloadThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final String id;
|
||||
@@ -143,6 +175,12 @@ public class DownloadRepository {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void delete(List<String> ids) {
|
||||
DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
|
||||
Thread thread = new Thread(delete);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static class DeleteThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final String id;
|
||||
@@ -157,4 +195,19 @@ public class DownloadRepository {
|
||||
downloadDao.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeleteMultipleThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final List<String> ids;
|
||||
|
||||
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
|
||||
this.downloadDao = downloadDao;
|
||||
this.ids = ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloadDao.deleteByIds(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||
import com.cappielloantonio.tempo.database.dao.LyricsDao;
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
|
||||
public class LyricsRepository {
|
||||
private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao();
|
||||
|
||||
public LyricsCache getLyrics(String songId) {
|
||||
GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId);
|
||||
Thread thread = new Thread(getLyricsThreadSafe);
|
||||
thread.start();
|
||||
|
||||
try {
|
||||
thread.join();
|
||||
return getLyricsThreadSafe.getLyrics();
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public LiveData<LyricsCache> observeLyrics(String songId) {
|
||||
return lyricsDao.observeOne(songId);
|
||||
}
|
||||
|
||||
public void insert(LyricsCache lyricsCache) {
|
||||
InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache);
|
||||
Thread thread = new Thread(insert);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void delete(String songId) {
|
||||
DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId);
|
||||
Thread thread = new Thread(delete);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static class GetLyricsThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final String songId;
|
||||
private LyricsCache lyricsCache;
|
||||
|
||||
public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.songId = songId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsCache = lyricsDao.getOne(songId);
|
||||
}
|
||||
|
||||
public LyricsCache getLyrics() {
|
||||
return lyricsCache;
|
||||
}
|
||||
}
|
||||
|
||||
private static class InsertThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final LyricsCache lyricsCache;
|
||||
|
||||
public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.lyricsCache = lyricsCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsDao.insert(lyricsCache);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeleteThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final String songId;
|
||||
|
||||
public DeleteThreadSafe(LyricsDao lyricsDao, String songId) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.songId = songId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsDao.delete(songId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,20 +81,24 @@ public class PlaylistRepository {
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, true, songsId, null)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
if (songsId.isEmpty()) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
|
||||
} else{
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, true, songsId, null)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
|
||||
@@ -131,23 +135,6 @@ public class PlaylistRepository {
|
||||
});
|
||||
}
|
||||
|
||||
public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList<String> songIdToAdd, ArrayList<Integer> songIndexToRemove) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void deletePlaylist(String playlistId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.media.audiofx.Equalizer
|
||||
|
||||
class EqualizerManager {
|
||||
|
||||
private var equalizer: Equalizer? = null
|
||||
|
||||
fun attachToSession(audioSessionId: Int): Boolean {
|
||||
release()
|
||||
if (audioSessionId != 0 && audioSessionId != -1) {
|
||||
try {
|
||||
equalizer = Equalizer(0, audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
// Some devices may not support Equalizer or audio session may be invalid
|
||||
equalizer = null
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setBandLevel(band: Short, level: Short) {
|
||||
equalizer?.setBandLevel(band, level)
|
||||
}
|
||||
|
||||
fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0
|
||||
|
||||
fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange
|
||||
|
||||
fun getCenterFreq(band: Short): Int? =
|
||||
equalizer?.getCenterFreq(band)?.div(1000)
|
||||
|
||||
fun getBandLevel(band: Short): Short? =
|
||||
equalizer?.getBandLevel(band)
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
equalizer?.enabled = enabled
|
||||
}
|
||||
|
||||
fun release() {
|
||||
equalizer?.release()
|
||||
equalizer = null
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.cappielloantonio.tempo.service;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Timeline;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.session.SessionToken;
|
||||
@@ -21,14 +27,79 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class MediaManager {
|
||||
private static final String TAG = "MediaManager";
|
||||
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
|
||||
|
||||
public static void registerPlaybackObserver(
|
||||
ListenableFuture<MediaBrowser> browserFuture,
|
||||
PlaybackViewModel playbackViewModel
|
||||
) {
|
||||
if (browserFuture == null) return;
|
||||
|
||||
Futures.addCallback(browserFuture, new FutureCallback<MediaBrowser>() {
|
||||
@Override
|
||||
public void onSuccess(MediaBrowser browser) {
|
||||
MediaBrowser current = attachedBrowserRef.get();
|
||||
if (current != browser) {
|
||||
browser.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onEvents(@NonNull Player player, @NonNull Player.Events events) {
|
||||
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)
|
||||
|| events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
|
||||
|| events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
|
||||
String mediaId = player.getCurrentMediaItem() != null
|
||||
? player.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
|
||||
boolean playing = player.getPlaybackState() == Player.STATE_READY
|
||||
&& player.getPlayWhenReady();
|
||||
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
String mediaId = browser.getCurrentMediaItem() != null
|
||||
? browser.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
|
||||
attachedBrowserRef = new WeakReference<>(browser);
|
||||
} else {
|
||||
String mediaId = browser.getCurrentMediaItem() != null
|
||||
? browser.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Log.e(TAG, "Failed to get MediaBrowser instance", t);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
public static void onBrowserReleased(@Nullable MediaBrowser released) {
|
||||
MediaBrowser attached = attachedBrowserRef.get();
|
||||
if (attached == released) {
|
||||
attachedBrowserRef.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static void reset(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
@@ -107,11 +178,24 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().seekTo(startIndex, 0);
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
browser.clearMediaItems();
|
||||
browser.setMediaItems(MappingUtil.mapMediaItems(media));
|
||||
browser.prepare();
|
||||
|
||||
Player.Listener timelineListener = new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
int itemCount = browser.getMediaItemCount();
|
||||
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
||||
browser.seekTo(startIndex, 0);
|
||||
browser.play();
|
||||
browser.removeListener(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
browser.addListener(timelineListener);
|
||||
|
||||
enqueueDatabase(media, true, 0);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
@@ -139,6 +223,25 @@ public class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static void playDownloadedMediaItem(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, MediaItem mediaItem) {
|
||||
if (mediaBrowserListenableFuture != null && mediaItem != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
mediaBrowser.clearMediaItems();
|
||||
mediaBrowser.setMediaItem(mediaItem);
|
||||
mediaBrowser.prepare();
|
||||
mediaBrowser.play();
|
||||
clearDatabase();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
}
|
||||
|
||||
public static void startRadio(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.util.Log;
|
||||
import com.cappielloantonio.tempo.subsonic.RetrofitClient;
|
||||
import com.cappielloantonio.tempo.subsonic.Subsonic;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
|
||||
@@ -21,7 +24,15 @@ public class SystemClient {
|
||||
|
||||
public Call<ApiResponse> ping() {
|
||||
Log.d(TAG, "ping()");
|
||||
return systemService.ping(subsonic.getParams());
|
||||
Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams());
|
||||
if (Preferences.isInUseServerAddressLocal()) {
|
||||
pingCall.timeout()
|
||||
.timeout(1, TimeUnit.SECONDS);
|
||||
} else {
|
||||
pingCall.timeout()
|
||||
.timeout(3, TimeUnit.SECONDS);
|
||||
}
|
||||
return pingCall;
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getLicense() {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.cappielloantonio.tempo.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
|
||||
@@ -13,7 +16,10 @@ import androidx.annotation.NonNull;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
@@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity {
|
||||
private BottomSheetBehavior bottomSheetBehavior;
|
||||
|
||||
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
|
||||
private Intent pendingDownloadPlaybackIntent;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -77,12 +84,16 @@ public class MainActivity extends BaseActivity {
|
||||
checkConnectionType();
|
||||
getOpenSubsonicExtensions();
|
||||
checkTempoUpdate();
|
||||
|
||||
maybeSchedulePlaybackIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
pingServer();
|
||||
initService();
|
||||
consumePendingPlaybackIntent();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,6 +109,14 @@ public class MainActivity extends BaseActivity {
|
||||
bind = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
maybeSchedulePlaybackIntent(intent);
|
||||
consumePendingPlaybackIntent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
|
||||
@@ -351,6 +370,7 @@ public class MainActivity extends BaseActivity {
|
||||
Preferences.switchInUseServerAddress();
|
||||
App.refreshSubsonicClient();
|
||||
pingServer();
|
||||
resetView();
|
||||
} else {
|
||||
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
|
||||
}
|
||||
@@ -361,6 +381,7 @@ public class MainActivity extends BaseActivity {
|
||||
Preferences.switchInUseServerAddress();
|
||||
App.refreshSubsonicClient();
|
||||
pingServer();
|
||||
resetView();
|
||||
} else {
|
||||
mainViewModel.ping().observe(this, subsonicResponse -> {
|
||||
if (subsonicResponse == null) {
|
||||
@@ -376,6 +397,13 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void resetView() {
|
||||
resetViewModel();
|
||||
int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
|
||||
navController.popBackStack(id, true);
|
||||
navController.navigate(id);
|
||||
}
|
||||
|
||||
private void getOpenSubsonicExtensions() {
|
||||
if (Preferences.getToken() != null) {
|
||||
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
|
||||
@@ -408,4 +436,68 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeSchedulePlaybackIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
|
||||
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
|
||||
pendingDownloadPlaybackIntent = new Intent(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private void consumePendingPlaybackIntent() {
|
||||
if (pendingDownloadPlaybackIntent == null) return;
|
||||
Intent intent = pendingDownloadPlaybackIntent;
|
||||
pendingDownloadPlaybackIntent = null;
|
||||
playDownloadedMedia(intent);
|
||||
}
|
||||
|
||||
private void playDownloadedMedia(Intent intent) {
|
||||
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = Uri.parse(uriString);
|
||||
String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
|
||||
if (TextUtils.isEmpty(mediaId)) {
|
||||
mediaId = uri.toString();
|
||||
}
|
||||
|
||||
String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
|
||||
String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
|
||||
String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
|
||||
int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
|
||||
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString("id", mediaId);
|
||||
extras.putString("title", title);
|
||||
extras.putString("artist", artist);
|
||||
extras.putString("album", album);
|
||||
extras.putString("uri", uri.toString());
|
||||
extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
|
||||
extras.putInt("duration", duration);
|
||||
|
||||
MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
|
||||
.setExtras(extras)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true);
|
||||
|
||||
if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
|
||||
if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
|
||||
if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadataBuilder.build())
|
||||
.setUri(uri)
|
||||
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||
.setRequestMetadata(new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setExtras(extras)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
|
||||
R.string.song_subtitle_formatter,
|
||||
song.getArtist(),
|
||||
MusicUtil.getReadableDurationString(song.getDuration(), false),
|
||||
""
|
||||
MusicUtil.getReadableAudioQualityString(song)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -23,17 +24,24 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
|
||||
private static final String TAG = "PlayerSongQueueAdapter";
|
||||
private final ClickCallback click;
|
||||
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
private List<Child> songs;
|
||||
|
||||
private String currentPlayingId;
|
||||
private boolean isPlaying;
|
||||
private List<Integer> currentPlayingPositions = Collections.emptyList();
|
||||
|
||||
public PlayerSongQueueAdapter(ClickCallback click) {
|
||||
this.click = click;
|
||||
this.songs = Collections.emptyList();
|
||||
@@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
||||
} else {
|
||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
int pos = holder.getBindingAdapterPosition();
|
||||
Child s = songs.get(pos);
|
||||
if (currentPlayingId != null && currentPlayingId.equals(s.getId())) {
|
||||
if (isPlaying) {
|
||||
mediaBrowser.pause();
|
||||
} else {
|
||||
mediaBrowser.play();
|
||||
}
|
||||
} else {
|
||||
mediaBrowser.seekTo(pos, 0);
|
||||
mediaBrowser.play();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Error obtaining MediaBrowser", e);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
|
||||
});
|
||||
bindPlaybackState(holder, song);
|
||||
}
|
||||
|
||||
private void bindPlaybackState(@NonNull PlayerSongQueueAdapter.ViewHolder holder, @NonNull Child song) {
|
||||
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
|
||||
|
||||
if (isCurrent) {
|
||||
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
|
||||
if (isPlaying) {
|
||||
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
|
||||
} else {
|
||||
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
|
||||
}
|
||||
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
|
||||
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Child> getItems() {
|
||||
@@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
||||
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
|
||||
}
|
||||
|
||||
public void setPlaybackState(String mediaId, boolean playing) {
|
||||
String oldId = this.currentPlayingId;
|
||||
boolean oldPlaying = this.isPlaying;
|
||||
List<Integer> oldPositions = currentPlayingPositions;
|
||||
|
||||
this.currentPlayingId = mediaId;
|
||||
this.isPlaying = playing;
|
||||
|
||||
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
|
||||
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
|
||||
if (oldPositions.equals(newPositionsCheck)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
|
||||
|
||||
for (int pos : oldPositions) {
|
||||
if (pos >= 0 && pos < songs.size()) {
|
||||
notifyItemChanged(pos, "payload_playback");
|
||||
}
|
||||
}
|
||||
for (int pos : currentPlayingPositions) {
|
||||
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
|
||||
notifyItemChanged(pos, "payload_playback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Integer> findPositionsById(String id) {
|
||||
if (id == null) return Collections.emptyList();
|
||||
List<Integer> positions = new ArrayList<>();
|
||||
for (int i = 0; i < songs.size(); i++) {
|
||||
if (id.equals(songs.get(i).getId())) {
|
||||
positions.add(i);
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
public Child getItem(int id) {
|
||||
return songs.get(id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.cappielloantonio.tempo.ui.adapter;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -9,7 +11,9 @@ import android.widget.Filterable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
@@ -21,8 +25,11 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -30,6 +37,7 @@ import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@UnstableApi
|
||||
public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAdapter.ViewHolder> implements Filterable {
|
||||
@@ -42,6 +50,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
private List<Child> songs;
|
||||
private String currentFilter;
|
||||
|
||||
private String currentPlayingId;
|
||||
private boolean isPlaying;
|
||||
private List<Integer> currentPlayingPositions = Collections.emptyList();
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
private final Filter filtering = new Filter() {
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint) {
|
||||
@@ -70,10 +83,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
protected void publishResults(CharSequence constraint, FilterResults results) {
|
||||
songs = (List<Child>) results.values;
|
||||
notifyDataSetChanged();
|
||||
|
||||
for (int pos : currentPlayingPositions) {
|
||||
if (pos >= 0 && pos < songs.size()) {
|
||||
notifyItemChanged(pos, "payload_playback");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public SongHorizontalAdapter(ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
|
||||
public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
|
||||
this.click = click;
|
||||
this.showCoverArt = showCoverArt;
|
||||
this.showAlbum = showAlbum;
|
||||
@@ -81,6 +100,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
this.songsFull = Collections.emptyList();
|
||||
this.currentFilter = "";
|
||||
this.album = album;
|
||||
setHasStableIds(false);
|
||||
|
||||
if (lifecycleOwner != null) {
|
||||
MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -91,7 +115,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
|
||||
if (!payloads.isEmpty() && payloads.contains("payload_playback")) {
|
||||
bindPlaybackState(holder, songs.get(position));
|
||||
} else {
|
||||
super.onBindViewHolder(holder, position, payloads);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
Child song = songs.get(position);
|
||||
|
||||
holder.item.searchResultSongTitleTextView.setText(song.getTitle());
|
||||
@@ -109,10 +142,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
|
||||
holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack()));
|
||||
|
||||
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
if (ExternalAudioReader.getUri(song) != null) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
if (showCoverArt) CustomGlideRequest.Builder
|
||||
@@ -165,6 +206,39 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
} else {
|
||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
bindPlaybackState(holder, song);
|
||||
}
|
||||
|
||||
private void handleExternalAudioRefresh() {
|
||||
if (Preferences.getDownloadDirectoryUri() != null) {
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
|
||||
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
|
||||
|
||||
if (isCurrent) {
|
||||
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
|
||||
if (isPlaying) {
|
||||
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
|
||||
} else {
|
||||
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
|
||||
}
|
||||
if (!showCoverArt) {
|
||||
holder.item.trackNumberTextView.setVisibility(View.INVISIBLE);
|
||||
} else {
|
||||
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
|
||||
}
|
||||
} else {
|
||||
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
|
||||
if (!showCoverArt) {
|
||||
holder.item.trackNumberTextView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -188,6 +262,46 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPlaybackState(String mediaId, boolean playing) {
|
||||
String oldId = this.currentPlayingId;
|
||||
boolean oldPlaying = this.isPlaying;
|
||||
List<Integer> oldPositions = currentPlayingPositions;
|
||||
|
||||
this.currentPlayingId = mediaId;
|
||||
this.isPlaying = playing;
|
||||
|
||||
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
|
||||
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
|
||||
if (oldPositions.equals(newPositionsCheck)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
|
||||
|
||||
for (int pos : oldPositions) {
|
||||
if (pos >= 0 && pos < songs.size()) {
|
||||
notifyItemChanged(pos, "payload_playback");
|
||||
}
|
||||
}
|
||||
for (int pos : currentPlayingPositions) {
|
||||
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
|
||||
notifyItemChanged(pos, "payload_playback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Integer> findPositionsById(String id) {
|
||||
if (id == null) return Collections.emptyList();
|
||||
List<Integer> positions = new ArrayList<>();
|
||||
for (int i = 0; i < songs.size(); i++) {
|
||||
if (id.equals(songs.get(i).getId())) {
|
||||
positions.add(i);
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Filter getFilter() {
|
||||
return filtering;
|
||||
@@ -215,11 +329,29 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
int pos = getBindingAdapterPosition();
|
||||
Child tappedSong = songs.get(pos);
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(MusicUtil.limitPlayableMedia(songs, getBindingAdapterPosition())));
|
||||
bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition()));
|
||||
|
||||
click.onMediaClick(bundle);
|
||||
if (tappedSong.getId().equals(currentPlayingId)) {
|
||||
Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback");
|
||||
try{
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying);
|
||||
if (isPlaying) {
|
||||
mediaBrowser.pause();
|
||||
} else {
|
||||
mediaBrowser.play();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e);
|
||||
}
|
||||
} else {
|
||||
click.onMediaClick(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean onLongClick() {
|
||||
@@ -247,4 +379,8 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setMediaBrowserListenableFuture(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
|
||||
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.cappielloantonio.tempo.ui.dialog;
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
@@ -12,6 +15,9 @@ import androidx.media3.common.util.UnstableApi;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.ExternalDownloadMetadataStore;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
@@ -42,7 +48,21 @@ public class DeleteDownloadStorageDialog extends DialogFragment {
|
||||
if (dialog != null) {
|
||||
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
||||
positiveButton.setOnClickListener(v -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
|
||||
}
|
||||
|
||||
String uriString = Preferences.getDownloadDirectoryUri();
|
||||
if (uriString != null) {
|
||||
DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString));
|
||||
if (directory != null && directory.canWrite()) {
|
||||
for (DocumentFile file : directory.listFiles()) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
ExternalAudioReader.refreshCache();
|
||||
ExternalDownloadMetadataStore.clear();
|
||||
}
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cappielloantonio.tempo.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
public class DownloadDirectoryPickerDialog extends DialogFragment {
|
||||
|
||||
private ActivityResultLauncher<Intent> folderPickerLauncher;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
// Register launcher *before* button triggers
|
||||
folderPickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == android.app.Activity.RESULT_OK) {
|
||||
Intent data = result.getData();
|
||||
if (data != null) {
|
||||
Uri uri = data.getData();
|
||||
if (uri != null) {
|
||||
requireContext().getContentResolver().takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
);
|
||||
|
||||
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||
ExternalAudioReader.refreshCache();
|
||||
|
||||
Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Set Download Directory")
|
||||
.setMessage("Choose a folder where downloaded songs will be stored.")
|
||||
.setPositiveButton("Choose Folder", (dialog, which) -> {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
folderPickerLauncher.launch(intent);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment {
|
||||
.setTitle(R.string.download_storage_dialog_title)
|
||||
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
|
||||
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
|
||||
.setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null)
|
||||
.create();
|
||||
}
|
||||
|
||||
@@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment {
|
||||
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
|
||||
neutralButton.setOnClickListener(v -> {
|
||||
int currentPreference = Preferences.getDownloadStoragePreference();
|
||||
int newPreference = 2;
|
||||
|
||||
if (currentPreference != newPreference) {
|
||||
Preferences.setDownloadStoragePreference(newPreference);
|
||||
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
|
||||
dialogClickCallback.onNeutralClick();
|
||||
}
|
||||
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
|
||||
|
||||
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
|
||||
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
@@ -100,8 +101,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
|
||||
public void onPlaylistClick(Bundle bundle) {
|
||||
if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) {
|
||||
Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
|
||||
playlistChooserViewModel.addSongsToPlaylist(playlist.getId());
|
||||
dismiss();
|
||||
playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId());
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.DialogStarredSyncBinding;
|
||||
import com.cappielloantonio.tempo.databinding.DialogStarredAlbumSyncBinding;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
@@ -26,10 +26,16 @@ import java.util.stream.Collectors;
|
||||
public class StarredAlbumSyncDialog extends DialogFragment {
|
||||
private StarredAlbumsSyncViewModel starredAlbumsSyncViewModel;
|
||||
|
||||
private Runnable onCancel;
|
||||
|
||||
public StarredAlbumSyncDialog(Runnable onCancel) {
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
DialogStarredSyncBinding bind = DialogStarredSyncBinding.inflate(getLayoutInflater());
|
||||
DialogStarredAlbumSyncBinding bind = DialogStarredAlbumSyncBinding.inflate(getLayoutInflater());
|
||||
|
||||
starredAlbumsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredAlbumsSyncViewModel.class);
|
||||
|
||||
@@ -74,6 +80,7 @@ public class StarredAlbumSyncDialog extends DialogFragment {
|
||||
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
|
||||
negativeButton.setOnClickListener(v -> {
|
||||
Preferences.setStarredAlbumsSyncEnabled(false);
|
||||
if (onCancel != null) onCancel.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cappielloantonio.tempo.ui.dialog;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.DialogStarredArtistSyncBinding;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.StarredArtistsSyncViewModel;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class StarredArtistSyncDialog extends DialogFragment {
|
||||
private StarredArtistsSyncViewModel starredArtistsSyncViewModel;
|
||||
|
||||
private Runnable onCancel;
|
||||
|
||||
public StarredArtistSyncDialog(Runnable onCancel) {
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
DialogStarredArtistSyncBinding bind = DialogStarredArtistSyncBinding.inflate(getLayoutInflater());
|
||||
|
||||
starredArtistsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredArtistsSyncViewModel.class);
|
||||
|
||||
return new MaterialAlertDialogBuilder(getActivity())
|
||||
.setView(bind.getRoot())
|
||||
.setTitle(R.string.starred_artist_sync_dialog_title)
|
||||
.setPositiveButton(R.string.starred_sync_dialog_positive_button, null)
|
||||
.setNeutralButton(R.string.starred_sync_dialog_neutral_button, null)
|
||||
.setNegativeButton(R.string.starred_sync_dialog_negative_button, null)
|
||||
.create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setButtonAction(requireContext());
|
||||
}
|
||||
|
||||
private void setButtonAction(Context context) {
|
||||
androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog();
|
||||
|
||||
if (dialog != null) {
|
||||
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
||||
positiveButton.setOnClickListener(v -> {
|
||||
starredArtistsSyncViewModel.getStarredArtistSongs(requireActivity()).observe(this, allSongs -> {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloadUtil.getDownloadTracker(context).download(
|
||||
MappingUtil.mapDownloads(allSongs),
|
||||
allSongs.stream().map(Download::new).collect(Collectors.toList())
|
||||
);
|
||||
}
|
||||
dialog.dismiss();
|
||||
});
|
||||
});
|
||||
|
||||
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
|
||||
neutralButton.setOnClickListener(v -> {
|
||||
Preferences.setStarredArtistsSyncEnabled(true);
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
|
||||
negativeButton.setOnClickListener(v -> {
|
||||
Preferences.setStarredArtistsSyncEnabled(false);
|
||||
if (onCancel != null) onCancel.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ import java.util.stream.Collectors;
|
||||
public class StarredSyncDialog extends DialogFragment {
|
||||
private StarredSyncViewModel starredSyncViewModel;
|
||||
|
||||
private Runnable onCancel;
|
||||
|
||||
public StarredSyncDialog(Runnable onCancel) {
|
||||
this.onCancel = onCancel;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
@@ -55,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment {
|
||||
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
||||
positiveButton.setOnClickListener(v -> {
|
||||
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
|
||||
if (songs != null) {
|
||||
if (songs != null && Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(context).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||
@@ -75,6 +81,7 @@ public class StarredSyncDialog extends DialogFragment {
|
||||
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
|
||||
negativeButton.setOnClickListener(v -> {
|
||||
Preferences.setStarredSyncEnabled(false);
|
||||
if (onCancel != null) onCancel.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.SearchView;
|
||||
import androidx.appcompat.widget.SearchView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
@@ -39,7 +39,10 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -52,6 +55,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
private FragmentAlbumPageBinding bind;
|
||||
private MainActivity activity;
|
||||
private AlbumPageViewModel albumPageViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
private SongHorizontalAdapter songHorizontalAdapter;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
@@ -74,6 +78,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentAlbumPageBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
initAppBar();
|
||||
@@ -91,6 +96,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
super.onStart();
|
||||
|
||||
initializeMediaBrowser();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -119,7 +132,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
|
||||
if (item.getItemId() == R.id.action_download_album) {
|
||||
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()));
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||
);
|
||||
} else {
|
||||
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -269,10 +289,15 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
bind.songRecyclerView.setHasFixedSize(true);
|
||||
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
|
||||
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
||||
setMediaBrowserListenableFuture();
|
||||
reapplyPlayback();
|
||||
|
||||
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
|
||||
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||
songHorizontalAdapter.setItems(songs);
|
||||
reapplyPlayback();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -295,4 +320,31 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
public void onMediaLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
@@ -29,19 +29,16 @@ import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumArtistPageOrSimilarAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.ArtistSimilarAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@UnstableApi
|
||||
@@ -49,6 +46,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
private FragmentArtistPageBinding bind;
|
||||
private MainActivity activity;
|
||||
private ArtistPageViewModel artistPageViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
|
||||
private SongHorizontalAdapter songHorizontalAdapter;
|
||||
private AlbumCatalogueAdapter albumCatalogueAdapter;
|
||||
@@ -63,6 +61,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentArtistPageBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
initAppBar();
|
||||
@@ -80,6 +79,13 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
super.onStart();
|
||||
|
||||
initializeMediaBrowser();
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -159,7 +165,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.artistPageRadioButton.setOnClickListener(v -> {
|
||||
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (!songs.isEmpty()) {
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
} else {
|
||||
@@ -172,8 +178,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
private void initTopSongsView() {
|
||||
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
|
||||
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
|
||||
setMediaBrowserListenableFuture();
|
||||
reapplyPlayback();
|
||||
artistPageViewModel.getArtistTopSongList().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (songs == null) {
|
||||
if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE);
|
||||
@@ -183,6 +191,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
if (bind != null)
|
||||
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
|
||||
songHorizontalAdapter.setItems(songs);
|
||||
reapplyPlayback();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -273,4 +282,31 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
public void onArtistLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
|
||||
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@@ -109,10 +111,14 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
|
||||
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
|
||||
if (isVisible() && getActivity() != null) {
|
||||
List<Child> songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||
);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||
);
|
||||
} else {
|
||||
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,11 +28,17 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
|
||||
import com.google.android.material.appbar.MaterialToolbar;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.app.Activity;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -40,6 +46,7 @@ import java.util.Objects;
|
||||
@UnstableApi
|
||||
public class DownloadFragment extends Fragment implements ClickCallback {
|
||||
private static final String TAG = "DownloadFragment";
|
||||
private static final int REQUEST_CODE_PICK_DIRECTORY = 1002;
|
||||
|
||||
private FragmentDownloadBinding bind;
|
||||
private MainActivity activity;
|
||||
@@ -129,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
});
|
||||
|
||||
downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> {
|
||||
if (count == null || bind == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (count == -1) {
|
||||
Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show();
|
||||
} else if (count == 0) {
|
||||
Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getResources().getQuantityString(R.plurals.download_refresh_removed, count, count),
|
||||
Toast.LENGTH_SHORT
|
||||
).show();
|
||||
}
|
||||
});
|
||||
|
||||
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
|
||||
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
|
||||
bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads());
|
||||
}
|
||||
|
||||
private void finishDownloadView(List<Child> songs) {
|
||||
@@ -216,6 +242,10 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
|
||||
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
|
||||
return true;
|
||||
} else if (menuItem.getItemId() == R.id.menu_download_set_directory) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -267,4 +297,21 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||
public void onDownloadGroupLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) {
|
||||
Uri uri = data.getData();
|
||||
if (uri != null) {
|
||||
requireContext().getContentResolver().takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
);
|
||||
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||
ExternalAudioReader.refreshCache();
|
||||
Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.cappielloantonio.tempo.ui.fragment
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.service.EqualizerManager
|
||||
import com.cappielloantonio.tempo.service.MediaService
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
|
||||
class EqualizerFragment : Fragment() {
|
||||
|
||||
private var equalizerManager: EqualizerManager? = null
|
||||
private lateinit var eqBandsContainer: LinearLayout
|
||||
private lateinit var eqSwitch: Switch
|
||||
private lateinit var resetButton: Button
|
||||
private lateinit var safeSpace: Space
|
||||
private val bandSeekBars = mutableListOf<SeekBar>()
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as MediaService.LocalBinder
|
||||
equalizerManager = binder.getEqualizerManager()
|
||||
initUI()
|
||||
restoreEqualizerPreferences()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
equalizerManager = null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Intent(requireContext(), MediaService::class.java).also { intent ->
|
||||
intent.action = MediaService.ACTION_BIND_EQUALIZER
|
||||
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
requireActivity().unbindService(connection)
|
||||
equalizerManager = null
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val root = inflater.inflate(R.layout.fragment_equalizer, container, false)
|
||||
eqSwitch = root.findViewById(R.id.equalizer_switch)
|
||||
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
|
||||
eqSwitch.jumpDrawablesToCurrentState()
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
eqBandsContainer = view.findViewById(R.id.eq_bands_container)
|
||||
resetButton = view.findViewById(R.id.equalizer_reset_button)
|
||||
safeSpace = view.findViewById(R.id.equalizer_bottom_space)
|
||||
}
|
||||
|
||||
private fun initUI() {
|
||||
val manager = equalizerManager
|
||||
val notSupportedView = view?.findViewById<LinearLayout>(R.id.equalizer_not_supported_container)
|
||||
val switchRow = view?.findViewById<View>(R.id.equalizer_switch_row)
|
||||
|
||||
if (manager == null || manager.getNumberOfBands().toInt() == 0) {
|
||||
switchRow?.visibility = View.GONE
|
||||
resetButton.visibility = View.GONE
|
||||
eqBandsContainer.visibility = View.GONE
|
||||
safeSpace.visibility = View.GONE
|
||||
notSupportedView?.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
notSupportedView?.visibility = View.GONE
|
||||
switchRow?.visibility = View.VISIBLE
|
||||
resetButton.visibility = View.VISIBLE
|
||||
eqBandsContainer.visibility = View.VISIBLE
|
||||
safeSpace.visibility = View.VISIBLE
|
||||
|
||||
eqSwitch.setOnCheckedChangeListener(null)
|
||||
updateUiEnabledState(eqSwitch.isChecked)
|
||||
eqSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
manager.setEnabled(isChecked)
|
||||
Preferences.setEqualizerEnabled(isChecked)
|
||||
updateUiEnabledState(isChecked)
|
||||
}
|
||||
|
||||
createBandSliders()
|
||||
|
||||
resetButton.setOnClickListener {
|
||||
resetEqualizer()
|
||||
saveBandLevelsToPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUiEnabledState(isEnabled: Boolean) {
|
||||
resetButton.isEnabled = isEnabled
|
||||
bandSeekBars.forEach { it.isEnabled = isEnabled }
|
||||
}
|
||||
|
||||
private fun formatDb(value: Int): String = if (value > 0) "+$value dB" else "$value dB"
|
||||
|
||||
private fun createBandSliders() {
|
||||
val manager = equalizerManager ?: return
|
||||
eqBandsContainer.removeAllViews()
|
||||
bandSeekBars.clear()
|
||||
val bands = manager.getNumberOfBands()
|
||||
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
|
||||
val minLevelDb = bandLevelRange[0] / 100
|
||||
val maxLevelDb = bandLevelRange[1] / 100
|
||||
|
||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||
for (i in 0 until bands) {
|
||||
val band = i.toShort()
|
||||
val freq = manager.getCenterFreq(band) ?: 0
|
||||
|
||||
val row = LinearLayout(requireContext()).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
val topBottomMarginDp = 16
|
||||
topMargin = topBottomMarginDp.dpToPx(context)
|
||||
bottomMargin = topBottomMarginDp.dpToPx(context)
|
||||
}
|
||||
setPadding(0, 8, 0, 8)
|
||||
}
|
||||
|
||||
val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
|
||||
text = if (freq >= 1000) {
|
||||
if (freq % 1000 == 0) {
|
||||
"${freq / 1000} kHz"
|
||||
} else {
|
||||
String.format("%.1f kHz", freq / 1000f)
|
||||
}
|
||||
} else {
|
||||
"$freq Hz"
|
||||
}
|
||||
gravity = Gravity.START
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
|
||||
}
|
||||
row.addView(freqLabel)
|
||||
|
||||
val initialLevelDb = (savedLevels.getOrNull(i) ?: (manager.getBandLevel(band) ?: 0)) / 100
|
||||
val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
|
||||
text = formatDb(initialLevelDb)
|
||||
setPadding(12, 0, 0, 0)
|
||||
gravity = Gravity.END
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
|
||||
}
|
||||
|
||||
val seekBar = SeekBar(requireContext()).apply {
|
||||
max = maxLevelDb - minLevelDb
|
||||
progress = initialLevelDb - minLevelDb
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 6f)
|
||||
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
val thisLevelDb = progress + minLevelDb
|
||||
if (fromUser) {
|
||||
manager.setBandLevel(band, (thisLevelDb * 100).toShort())
|
||||
saveBandLevelsToPreferences()
|
||||
}
|
||||
dbLabel.text = formatDb(thisLevelDb)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {}
|
||||
})
|
||||
}
|
||||
bandSeekBars.add(seekBar)
|
||||
row.addView(seekBar)
|
||||
row.addView(dbLabel)
|
||||
eqBandsContainer.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetEqualizer() {
|
||||
val manager = equalizerManager ?: return
|
||||
val bands = manager.getNumberOfBands()
|
||||
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
|
||||
val minLevelDb = bandLevelRange[0] / 100
|
||||
val midLevelDb = 0
|
||||
|
||||
for (i in 0 until bands) {
|
||||
manager.setBandLevel(i.toShort(), (0).toShort())
|
||||
bandSeekBars.getOrNull(i)?.progress = midLevelDb - minLevelDb
|
||||
}
|
||||
Preferences.setEqualizerBandLevels(ShortArray(bands.toInt()))
|
||||
}
|
||||
|
||||
private fun saveBandLevelsToPreferences() {
|
||||
val manager = equalizerManager ?: return
|
||||
val bands = manager.getNumberOfBands()
|
||||
val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 }
|
||||
Preferences.setEqualizerBandLevels(levels)
|
||||
}
|
||||
|
||||
private fun restoreEqualizerPreferences() {
|
||||
val manager = equalizerManager ?: return
|
||||
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
|
||||
updateUiEnabledState(eqSwitch.isChecked)
|
||||
|
||||
val bands = manager.getNumberOfBands()
|
||||
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
|
||||
val minLevelDb = bandLevelRange[0] / 100
|
||||
|
||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||
for (i in 0 until bands) {
|
||||
val savedDb = savedLevels[i] / 100
|
||||
manager.setBandLevel(i.toShort(), (savedDb * 100).toShort())
|
||||
bandSeekBars.getOrNull(i)?.progress = savedDb - minLevelDb
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(context: Context): Int =
|
||||
(this * context.resources.displayMetrics.density).toInt()
|
||||
@@ -9,6 +9,7 @@ import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -40,6 +41,7 @@ import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Share;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
|
||||
@@ -60,9 +62,12 @@ import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.util.UIUtil;
|
||||
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -74,6 +79,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
private FragmentHomeTabMusicBinding bind;
|
||||
private MainActivity activity;
|
||||
private HomeViewModel homeViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
|
||||
private DiscoverSongAdapter discoverSongAdapter;
|
||||
private SimilarTrackAdapter similarMusicAdapter;
|
||||
@@ -101,6 +107,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentHomeTabMusicBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
|
||||
@@ -113,6 +120,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
initSyncStarredView();
|
||||
initSyncStarredAlbumsView();
|
||||
initSyncStarredArtistsView();
|
||||
initDiscoverSongSlideView();
|
||||
initSimilarSongView();
|
||||
initArtistRadio();
|
||||
@@ -138,12 +146,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
super.onStart();
|
||||
|
||||
initializeMediaBrowser();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observeStarredSongsPlayback();
|
||||
observeTopSongsPlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
refreshSharesView();
|
||||
if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture();
|
||||
if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -265,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
|
||||
private void initSyncStarredView() {
|
||||
if (Preferences.isStarredSyncEnabled()) {
|
||||
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> songs) {
|
||||
@@ -318,32 +332,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
private void initSyncStarredAlbumsView() {
|
||||
if (Preferences.isStarredAlbumsSyncEnabled()) {
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observeForever(new Observer<List<AlbumID3>>() {
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<AlbumID3> albums) {
|
||||
if (albums != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
List<String> albumsToSync = new ArrayList<>();
|
||||
int albumCount = 0;
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean needsSync = false;
|
||||
albumCount++;
|
||||
albumsToSync.add(album.getName());
|
||||
}
|
||||
|
||||
if (albumCount > 0) {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
|
||||
String message = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_albums_count,
|
||||
albumCount,
|
||||
albumCount
|
||||
);
|
||||
bind.homeSyncStarredAlbumsToSync.setText(message);
|
||||
}
|
||||
if (albums != null && !albums.isEmpty()) {
|
||||
checkIfAlbumsNeedSync(albums);
|
||||
}
|
||||
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).removeObserver(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -353,26 +347,157 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
});
|
||||
|
||||
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
|
||||
homeViewModel.getAllStarredAlbumSongs().observeForever(new Observer<List<Child>>() {
|
||||
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
homeViewModel.getAllStarredAlbumSongs().removeObserver(this);
|
||||
if (songsToDownload > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void checkIfAlbumsNeedSync(List<AlbumID3> albums) {
|
||||
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> albumsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean albumNeedsSync = false;
|
||||
// Check if any songs from this album need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
albumNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (albumNeedsSync) {
|
||||
albumsNeedingSync.add(album.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
|
||||
String message = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_albums_count,
|
||||
albumsNeedingSync.size(),
|
||||
albumsNeedingSync.size()
|
||||
);
|
||||
bind.homeSyncStarredAlbumsToSync.setText(message);
|
||||
} else {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initSyncStarredArtistsView() {
|
||||
if (Preferences.isStarredArtistsSyncEnabled()) {
|
||||
homeViewModel.getStarredArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<ArtistID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<ArtistID3> artists) {
|
||||
if (artists != null && !artists.isEmpty()) {
|
||||
checkIfArtistsNeedSync(artists);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
});
|
||||
|
||||
bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
|
||||
homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void checkIfArtistsNeedSync(List<ArtistID3> artists) {
|
||||
homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> artistsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
boolean artistNeedsSync = false;
|
||||
// Check if any songs from this artist need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
artistNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (artistNeedsSync) {
|
||||
artistsNeedingSync.add(artist.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
|
||||
String message = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_artists_count,
|
||||
artistsNeedingSync.size(),
|
||||
artistsNeedingSync.size()
|
||||
);
|
||||
bind.homeSyncStarredArtistsToSync.setText(message);
|
||||
} else {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initDiscoverSongSlideView() {
|
||||
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
|
||||
|
||||
@@ -475,8 +600,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.topSongsRecyclerView.setHasFixedSize(true);
|
||||
|
||||
topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
|
||||
topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
|
||||
setTopSongsMediaBrowserListenableFuture();
|
||||
reapplyTopSongsPlayback();
|
||||
homeViewModel.getChronologySample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), chronologies -> {
|
||||
if (chronologies == null || chronologies.isEmpty()) {
|
||||
if (bind != null) bind.homeGridTracksSector.setVisibility(View.GONE);
|
||||
@@ -492,6 +619,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
topSongAdapter.setItems(topSongs);
|
||||
reapplyTopSongsPlayback();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -513,8 +641,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.starredTracksRecyclerView.setHasFixedSize(true);
|
||||
|
||||
starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
|
||||
starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
|
||||
setStarredSongsMediaBrowserListenableFuture();
|
||||
reapplyStarredSongsPlayback();
|
||||
homeViewModel.getStarredTracks(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
|
||||
if (songs == null) {
|
||||
if (bind != null) bind.starredTracksSector.setVisibility(View.GONE);
|
||||
@@ -525,6 +655,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false));
|
||||
|
||||
starredSongAdapter.setItems(songs);
|
||||
reapplyStarredSongsPlayback();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -954,6 +1085,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
|
||||
activity.setBottomSheetInPeek(true);
|
||||
}
|
||||
topSongAdapter.notifyDataSetChanged();
|
||||
starredSongAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1043,4 +1176,58 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
public void onShareLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observeStarredSongsPlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (starredSongAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
starredSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (starredSongAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
starredSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void observeTopSongsPlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (topSongAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
topSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (topSongAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
topSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyStarredSongsPlayback() {
|
||||
if (starredSongAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
starredSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void reapplyTopSongsPlayback() {
|
||||
if (topSongAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
topSongAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setTopSongsMediaBrowserListenableFuture() {
|
||||
topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
|
||||
private void setStarredSongsMediaBrowserListenableFuture() {
|
||||
starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -24,11 +28,14 @@ import androidx.media3.common.util.RepeatModeUtil;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.session.SessionToken;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.NavOptions;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding;
|
||||
import com.cappielloantonio.tempo.service.EqualizerManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
||||
@@ -68,11 +75,15 @@ public class PlayerControllerFragment extends Fragment {
|
||||
private ImageButton playerOpenQueueButton;
|
||||
private ImageButton playerTrackInfo;
|
||||
private LinearLayout ratingContainer;
|
||||
private ImageButton equalizerButton;
|
||||
|
||||
private MainActivity activity;
|
||||
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
private MediaService.LocalBinder mediaServiceBinder;
|
||||
private boolean isServiceBound = false;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
activity = (MainActivity) getActivity();
|
||||
@@ -89,6 +100,7 @@ public class PlayerControllerFragment extends Fragment {
|
||||
initMediaListenable();
|
||||
initMediaLabelButton();
|
||||
initArtistLabelButton();
|
||||
initEqualizerButton();
|
||||
|
||||
return view;
|
||||
}
|
||||
@@ -126,6 +138,7 @@ public class PlayerControllerFragment extends Fragment {
|
||||
playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track);
|
||||
songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar);
|
||||
ratingContainer = bind.getRoot().findViewById(R.id.rating_container);
|
||||
equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button);
|
||||
checkAndSetRatingContainerVisibility();
|
||||
}
|
||||
|
||||
@@ -426,6 +439,18 @@ public class PlayerControllerFragment extends Fragment {
|
||||
});
|
||||
}
|
||||
|
||||
private void initEqualizerButton() {
|
||||
equalizerButton.setOnClickListener(v -> {
|
||||
NavController navController = NavHostFragment.findNavController(this);
|
||||
NavOptions navOptions = new NavOptions.Builder()
|
||||
.setLaunchSingleTop(true)
|
||||
.setPopUpTo(R.id.equalizerFragment, true)
|
||||
.build();
|
||||
navController.navigate(R.id.equalizerFragment, null, navOptions);
|
||||
if (activity != null) activity.collapseBottomSheetDelayed();
|
||||
});
|
||||
}
|
||||
|
||||
public void goToControllerPage() {
|
||||
playerMediaCoverViewPager.setCurrentItem(0, false);
|
||||
}
|
||||
@@ -461,4 +486,66 @@ public class PlayerControllerFragment extends Fragment {
|
||||
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
|
||||
// TODO Resettare lo skip del silenzio
|
||||
}
|
||||
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
mediaServiceBinder = (MediaService.LocalBinder) service;
|
||||
isServiceBound = true;
|
||||
checkEqualizerBands();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
mediaServiceBinder = null;
|
||||
isServiceBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
private void bindMediaService() {
|
||||
Intent intent = new Intent(requireActivity(), MediaService.class);
|
||||
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
|
||||
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
|
||||
isServiceBound = true;
|
||||
}
|
||||
|
||||
private void checkEqualizerBands() {
|
||||
if (mediaServiceBinder != null) {
|
||||
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
|
||||
short numBands = eqManager.getNumberOfBands();
|
||||
|
||||
if (equalizerButton != null) {
|
||||
if (numBands == 0) {
|
||||
equalizerButton.setVisibility(View.GONE);
|
||||
|
||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
|
||||
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
|
||||
playerOpenQueueButton.setLayoutParams(params);
|
||||
} else {
|
||||
equalizerButton.setVisibility(View.VISIBLE);
|
||||
|
||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
|
||||
params.startToStart = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.startToEnd = R.id.player_open_equalizer_button;
|
||||
playerOpenQueueButton.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
bindMediaService();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (isServiceBound) {
|
||||
requireActivity().unbindService(serviceConnection);
|
||||
isServiceBound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
@@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
@@ -115,10 +117,14 @@ public class PlayerCoverFragment extends Fragment {
|
||||
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
|
||||
if (song != null && bind != null) {
|
||||
bind.innerButtonTopLeft.setOnClickListener(view -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
} else {
|
||||
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||
}
|
||||
});
|
||||
|
||||
bind.innerButtonTopRight.setOnClickListener(view -> {
|
||||
|
||||
@@ -4,15 +4,16 @@ import android.annotation.SuppressLint;
|
||||
import android.content.ComponentName;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Layout;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -29,10 +30,10 @@ import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Line;
|
||||
import com.cappielloantonio.tempo.subsonic.models.LyricsList;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.List;
|
||||
@@ -48,6 +49,9 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
private MediaBrowser mediaBrowser;
|
||||
private Handler syncLyricsHandler;
|
||||
private Runnable syncLyricsRunnable;
|
||||
private String currentLyrics;
|
||||
private LyricsList currentLyricsList;
|
||||
private String currentDescription;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
@@ -66,6 +70,7 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
initPanelContent();
|
||||
observeDownloadState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,12 +106,26 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
bind = null;
|
||||
currentLyrics = null;
|
||||
currentLyricsList = null;
|
||||
currentDescription = null;
|
||||
}
|
||||
|
||||
private void initOverlay() {
|
||||
bind.syncLyricsTapButton.setOnClickListener(view -> {
|
||||
playerBottomSheetViewModel.changeSyncLyricsState();
|
||||
});
|
||||
|
||||
bind.downloadLyricsButton.setOnClickListener(view -> {
|
||||
boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics();
|
||||
if (getContext() != null) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
saved ? R.string.player_lyrics_download_success : R.string.player_lyrics_download_failure,
|
||||
Toast.LENGTH_SHORT
|
||||
).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeBrowser() {
|
||||
@@ -136,50 +155,91 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void initPanelContent() {
|
||||
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
|
||||
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
|
||||
setPanelContent(null, lyricsList);
|
||||
});
|
||||
} else {
|
||||
playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
|
||||
setPanelContent(lyrics, null);
|
||||
});
|
||||
}
|
||||
playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
|
||||
currentLyrics = lyrics;
|
||||
updatePanelContent();
|
||||
});
|
||||
|
||||
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
|
||||
currentLyricsList = lyricsList;
|
||||
updatePanelContent();
|
||||
});
|
||||
|
||||
playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
|
||||
currentDescription = description;
|
||||
updatePanelContent();
|
||||
});
|
||||
}
|
||||
|
||||
private void setPanelContent(String lyrics, LyricsList lyricsList) {
|
||||
playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
|
||||
private void observeDownloadState() {
|
||||
playerBottomSheetViewModel.getLyricsCachedState().observe(getViewLifecycleOwner(), cached -> {
|
||||
if (bind != null) {
|
||||
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
|
||||
|
||||
if (lyrics != null && !lyrics.trim().equals("")) {
|
||||
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(lyrics));
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
} else if (lyricsList != null && lyricsList.getStructuredLyrics() != null) {
|
||||
setSyncLirics(lyricsList);
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
|
||||
} else if (description != null && !description.trim().equals("")) {
|
||||
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(description));
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
MaterialButton downloadButton = (MaterialButton) bind.downloadLyricsButton;
|
||||
if (cached != null && cached) {
|
||||
downloadButton.setIconResource(R.drawable.ic_done);
|
||||
downloadButton.setContentDescription(getString(R.string.player_lyrics_downloaded_content_description));
|
||||
} else {
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
downloadButton.setIconResource(R.drawable.ic_download);
|
||||
downloadButton.setContentDescription(getString(R.string.player_lyrics_download_content_description));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updatePanelContent() {
|
||||
if (bind == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
|
||||
|
||||
if (hasStructuredLyrics(currentLyricsList)) {
|
||||
setSyncLirics(currentLyricsList);
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
|
||||
bind.downloadLyricsButton.setVisibility(View.VISIBLE);
|
||||
bind.downloadLyricsButton.setEnabled(true);
|
||||
} else if (hasText(currentLyrics)) {
|
||||
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentLyrics));
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
bind.downloadLyricsButton.setVisibility(View.VISIBLE);
|
||||
bind.downloadLyricsButton.setEnabled(true);
|
||||
} else if (hasText(currentDescription)) {
|
||||
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentDescription));
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.GONE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
bind.downloadLyricsButton.setVisibility(View.GONE);
|
||||
bind.downloadLyricsButton.setEnabled(false);
|
||||
} else {
|
||||
bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
|
||||
bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
|
||||
bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
|
||||
bind.syncLyricsTapButton.setVisibility(View.GONE);
|
||||
bind.downloadLyricsButton.setVisibility(View.GONE);
|
||||
bind.downloadLyricsButton.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private boolean hasStructuredLyrics(LyricsList lyricsList) {
|
||||
return lyricsList != null
|
||||
&& lyricsList.getStructuredLyrics() != null
|
||||
&& !lyricsList.getStructuredLyrics().isEmpty()
|
||||
&& lyricsList.getStructuredLyrics().get(0) != null
|
||||
&& lyricsList.getStructuredLyrics().get(0).getLine() != null
|
||||
&& !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
private void setSyncLirics(LyricsList lyricsList) {
|
||||
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
|
||||
@@ -198,28 +258,28 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
|
||||
private void defineProgressHandler() {
|
||||
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
|
||||
if (lyricsList != null) {
|
||||
|
||||
if (lyricsList.getStructuredLyrics() != null && lyricsList.getStructuredLyrics().get(0) != null && !lyricsList.getStructuredLyrics().get(0).getSynced()) {
|
||||
releaseHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
syncLyricsHandler = new Handler();
|
||||
syncLyricsRunnable = () -> {
|
||||
if (syncLyricsHandler != null) {
|
||||
if (bind != null) {
|
||||
displaySyncedLyrics();
|
||||
}
|
||||
|
||||
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
|
||||
}
|
||||
};
|
||||
|
||||
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
|
||||
} else {
|
||||
if (!hasStructuredLyrics(lyricsList)) {
|
||||
releaseHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lyricsList.getStructuredLyrics().get(0).getSynced()) {
|
||||
releaseHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
syncLyricsHandler = new Handler();
|
||||
syncLyricsRunnable = () -> {
|
||||
if (syncLyricsHandler != null) {
|
||||
if (bind != null) {
|
||||
displaySyncedLyrics();
|
||||
}
|
||||
|
||||
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
|
||||
}
|
||||
};
|
||||
|
||||
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +287,7 @@ public class PlayerLyricsFragment extends Fragment {
|
||||
LyricsList lyricsList = playerBottomSheetViewModel.getLiveLyricsList().getValue();
|
||||
int timestamp = (int) (mediaBrowser.getCurrentPosition());
|
||||
|
||||
if (lyricsList != null && lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
|
||||
if (hasStructuredLyrics(lyricsList)) {
|
||||
StringBuilder lyricsBuilder = new StringBuilder();
|
||||
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
@@ -38,6 +39,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
private InnerFragmentPlayerQueueBinding bind;
|
||||
|
||||
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
private PlayerSongQueueAdapter playerSongQueueAdapter;
|
||||
@@ -48,6 +50,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
View view = bind.getRoot();
|
||||
|
||||
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
initQueueRecyclerView();
|
||||
|
||||
@@ -59,6 +62,9 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
super.onStart();
|
||||
initializeBrowser();
|
||||
bindMediaController();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,9 +116,12 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
|
||||
playerSongQueueAdapter = new PlayerSongQueueAdapter(this);
|
||||
bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter);
|
||||
reapplyPlayback();
|
||||
|
||||
playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> {
|
||||
if (queue != null) {
|
||||
playerSongQueueAdapter.setItems(queue.stream().map(item -> (Child) item).collect(Collectors.toList()));
|
||||
reapplyPlayback();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,4 +225,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
public void onMediaClick(Bundle bundle) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (playerSongQueueAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (playerSongQueueAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (playerSongQueueAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@@ -49,6 +52,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
private FragmentPlaylistPageBinding bind;
|
||||
private MainActivity activity;
|
||||
private PlaylistPageViewModel playlistPageViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
|
||||
private SongHorizontalAdapter songHorizontalAdapter;
|
||||
|
||||
@@ -94,6 +98,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentPlaylistPageBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
playlistPageViewModel = new ViewModelProvider(requireActivity()).get(PlaylistPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
initAppBar();
|
||||
@@ -109,6 +114,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
super.onStart();
|
||||
|
||||
initializeMediaBrowser();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -128,7 +142,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
if (item.getItemId() == R.id.action_download_playlist) {
|
||||
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (isVisible() && getActivity() != null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(child -> {
|
||||
Download toDownload = new Download(child);
|
||||
@@ -136,7 +151,10 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
|
||||
return toDownload;
|
||||
}).collect(Collectors.toList())
|
||||
);
|
||||
);
|
||||
} else {
|
||||
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
@@ -246,10 +264,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
bind.songRecyclerView.setHasFixedSize(true);
|
||||
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
||||
setMediaBrowserListenableFuture();
|
||||
reapplyPlayback();
|
||||
|
||||
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
|
||||
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||
songHorizontalAdapter.setItems(songs);
|
||||
reapplyPlayback();
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeMediaBrowser() {
|
||||
@@ -270,4 +293,31 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||
public void onMediaLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import android.content.ComponentName;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -34,6 +31,7 @@ import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.SearchViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@@ -46,6 +44,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
private FragmentSearchBinding bind;
|
||||
private MainActivity activity;
|
||||
private SearchViewModel searchViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
|
||||
private ArtistAdapter artistAdapter;
|
||||
private AlbumAdapter albumAdapter;
|
||||
@@ -61,6 +60,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentSearchBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
searchViewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
initSearchResultView();
|
||||
initSearchView();
|
||||
@@ -73,6 +73,15 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
initializeMediaBrowser();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -112,7 +121,10 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
|
||||
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||
setMediaBrowserListenableFuture();
|
||||
reapplyPlayback();
|
||||
|
||||
bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter);
|
||||
}
|
||||
|
||||
@@ -242,7 +254,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
|
||||
private boolean isQueryValid(String query) {
|
||||
return !query.equals("") && query.trim().length() > 2;
|
||||
return !query.equals("") && query.trim().length() > 1;
|
||||
}
|
||||
|
||||
private void inputFocus() {
|
||||
@@ -260,6 +272,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onMediaClick(Bundle bundle) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
|
||||
songHorizontalAdapter.notifyDataSetChanged();
|
||||
activity.setBottomSheetInPeek(true);
|
||||
}
|
||||
|
||||
@@ -287,4 +300,31 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||
public void onArtistLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.media.audiofx.AudioEffect;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
@@ -18,24 +24,32 @@ import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.NavOptions;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.preference.ListPreference;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
import androidx.preference.SwitchPreference;
|
||||
|
||||
import com.cappielloantonio.tempo.BuildConfig;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.helper.ThemeHelper;
|
||||
import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
|
||||
import com.cappielloantonio.tempo.interfaces.ScanCallback;
|
||||
import com.cappielloantonio.tempo.service.EqualizerManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.util.UIUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -48,15 +62,41 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
private MainActivity activity;
|
||||
private SettingViewModel settingViewModel;
|
||||
|
||||
private ActivityResultLauncher<Intent> someActivityResultLauncher;
|
||||
private ActivityResultLauncher<Intent> equalizerResultLauncher;
|
||||
private ActivityResultLauncher<Intent> directoryPickerLauncher;
|
||||
|
||||
private MediaService.LocalBinder mediaServiceBinder;
|
||||
private boolean isServiceBound = false;
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
someActivityResultLauncher = registerForActivityResult(
|
||||
equalizerResultLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {}
|
||||
);
|
||||
|
||||
directoryPickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK) {
|
||||
Intent data = result.getData();
|
||||
if (data != null) {
|
||||
Uri uri = data.getData();
|
||||
if (uri != null) {
|
||||
requireContext().getContentResolver().takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
);
|
||||
|
||||
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||
ExternalAudioReader.refreshCache();
|
||||
Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show();
|
||||
checkDownloadDirectory();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,9 +125,10 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
checkEqualizer();
|
||||
checkSystemEqualizer();
|
||||
checkCacheStorage();
|
||||
checkStorage();
|
||||
checkDownloadDirectory();
|
||||
|
||||
setStreamingCacheSize();
|
||||
setAppLanguage();
|
||||
@@ -97,10 +138,17 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
actionScan();
|
||||
actionSyncStarredAlbums();
|
||||
actionSyncStarredTracks();
|
||||
actionSyncStarredArtists();
|
||||
actionChangeStreamingCacheStorage();
|
||||
actionChangeDownloadStorage();
|
||||
actionSetDownloadDirectory();
|
||||
actionDeleteDownloadStorage();
|
||||
actionKeepScreenOn();
|
||||
actionAutoDownloadLyrics();
|
||||
actionMiniPlayerHeart();
|
||||
|
||||
bindMediaService();
|
||||
actionAppEqualizer();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -123,8 +171,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkEqualizer() {
|
||||
Preference equalizer = findPreference("equalizer");
|
||||
private void checkSystemEqualizer() {
|
||||
Preference equalizer = findPreference("system_equalizer");
|
||||
|
||||
if (equalizer == null) return;
|
||||
|
||||
@@ -132,7 +180,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
|
||||
equalizer.setOnPreferenceClickListener(preference -> {
|
||||
someActivityResultLauncher.launch(intent);
|
||||
equalizerResultLauncher.launch(intent);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
@@ -149,7 +197,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
||||
storage.setVisible(false);
|
||||
} else {
|
||||
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
||||
storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
storage.setVisible(false);
|
||||
@@ -165,13 +213,46 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
||||
storage.setVisible(false);
|
||||
} else {
|
||||
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
||||
int pref = Preferences.getDownloadStoragePreference();
|
||||
if (pref == 0) {
|
||||
storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
|
||||
} else if (pref == 1) {
|
||||
storage.setSummary(R.string.download_storage_external_dialog_positive_button);
|
||||
} else {
|
||||
storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
storage.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkDownloadDirectory() {
|
||||
Preference storage = findPreference("download_storage");
|
||||
Preference directory = findPreference("set_download_directory");
|
||||
|
||||
if (directory == null) return;
|
||||
|
||||
String current = Preferences.getDownloadDirectoryUri();
|
||||
if (current != null) {
|
||||
if (storage != null) storage.setVisible(false);
|
||||
directory.setVisible(true);
|
||||
directory.setIcon(R.drawable.ic_close);
|
||||
directory.setTitle(R.string.settings_clear_download_folder);
|
||||
directory.setSummary(current);
|
||||
} else {
|
||||
if (storage != null) storage.setVisible(true);
|
||||
if (Preferences.getDownloadStoragePreference() == 2) {
|
||||
directory.setVisible(true);
|
||||
directory.setIcon(R.drawable.ic_folder);
|
||||
directory.setTitle(R.string.settings_set_download_folder);
|
||||
directory.setSummary(R.string.settings_choose_download_folder);
|
||||
} else {
|
||||
directory.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setStreamingCacheSize() {
|
||||
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
|
||||
|
||||
@@ -244,7 +325,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
@Override
|
||||
public void onSuccess(boolean isScanning, long count) {
|
||||
findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
|
||||
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
|
||||
if (isScanning) getScanStatus();
|
||||
}
|
||||
});
|
||||
@@ -257,9 +338,11 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
findPreference("sync_starred_tracks_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
if (newValue instanceof Boolean) {
|
||||
if ((Boolean) newValue) {
|
||||
StarredSyncDialog dialog = new StarredSyncDialog();
|
||||
StarredSyncDialog dialog = new StarredSyncDialog(() -> {
|
||||
((SwitchPreference)preference).setChecked(false);
|
||||
});
|
||||
dialog.show(activity.getSupportFragmentManager(), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -269,14 +352,30 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
findPreference("sync_starred_albums_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
if (newValue instanceof Boolean) {
|
||||
if ((Boolean) newValue) {
|
||||
StarredAlbumSyncDialog dialog = new StarredAlbumSyncDialog();
|
||||
StarredAlbumSyncDialog dialog = new StarredAlbumSyncDialog(() -> {
|
||||
((SwitchPreference)preference).setChecked(false);
|
||||
});
|
||||
dialog.show(activity.getSupportFragmentManager(), null);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void actionSyncStarredArtists() {
|
||||
findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
if (newValue instanceof Boolean) {
|
||||
if ((Boolean) newValue) {
|
||||
StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> {
|
||||
((SwitchPreference)preference).setChecked(false);
|
||||
});
|
||||
dialog.show(activity.getSupportFragmentManager(), null);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void actionChangeStreamingCacheStorage() {
|
||||
findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> {
|
||||
StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() {
|
||||
@@ -301,11 +400,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
@Override
|
||||
public void onPositiveClick() {
|
||||
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
|
||||
checkDownloadDirectory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNegativeClick() {
|
||||
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
|
||||
checkDownloadDirectory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNeutralClick() {
|
||||
findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
|
||||
checkDownloadDirectory();
|
||||
}
|
||||
});
|
||||
dialog.show(activity.getSupportFragmentManager(), null);
|
||||
@@ -313,6 +420,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
});
|
||||
}
|
||||
|
||||
private void actionSetDownloadDirectory() {
|
||||
Preference pref = findPreference("set_download_directory");
|
||||
if (pref != null) {
|
||||
pref.setOnPreferenceClickListener(preference -> {
|
||||
String current = Preferences.getDownloadDirectoryUri();
|
||||
|
||||
if (current != null) {
|
||||
Preferences.setDownloadDirectoryUri(null);
|
||||
Preferences.setDownloadStoragePreference(0);
|
||||
ExternalAudioReader.refreshCache();
|
||||
Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
|
||||
checkStorage();
|
||||
checkDownloadDirectory();
|
||||
} else {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
directoryPickerLauncher.launch(intent);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void actionDeleteDownloadStorage() {
|
||||
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
|
||||
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
|
||||
@@ -321,6 +453,36 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
});
|
||||
}
|
||||
|
||||
private void actionMiniPlayerHeart() {
|
||||
SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
|
||||
if (preference == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
preference.setChecked(Preferences.showShuffleInsteadOfHeart());
|
||||
preference.setOnPreferenceChangeListener((pref, newValue) -> {
|
||||
if (newValue instanceof Boolean) {
|
||||
Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void actionAutoDownloadLyrics() {
|
||||
SwitchPreference preference = findPreference("auto_download_lyrics");
|
||||
if (preference == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
|
||||
preference.setOnPreferenceChangeListener((pref, newValue) -> {
|
||||
if (newValue instanceof Boolean) {
|
||||
Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void getScanStatus() {
|
||||
settingViewModel.getScanStatus(new ScanCallback() {
|
||||
@Override
|
||||
@@ -330,7 +492,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
@Override
|
||||
public void onSuccess(boolean isScanning, long count) {
|
||||
findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
|
||||
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
|
||||
if (isScanning) getScanStatus();
|
||||
}
|
||||
});
|
||||
@@ -348,4 +510,63 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
mediaServiceBinder = (MediaService.LocalBinder) service;
|
||||
isServiceBound = true;
|
||||
checkEqualizerBands();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
mediaServiceBinder = null;
|
||||
isServiceBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
private void bindMediaService() {
|
||||
Intent intent = new Intent(requireActivity(), MediaService.class);
|
||||
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
|
||||
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
|
||||
isServiceBound = true;
|
||||
}
|
||||
|
||||
private void checkEqualizerBands() {
|
||||
if (mediaServiceBinder != null) {
|
||||
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
|
||||
short numBands = eqManager.getNumberOfBands();
|
||||
Preference appEqualizer = findPreference("app_equalizer");
|
||||
if (appEqualizer != null) {
|
||||
appEqualizer.setVisible(numBands > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void actionAppEqualizer() {
|
||||
Preference appEqualizer = findPreference("app_equalizer");
|
||||
if (appEqualizer != null) {
|
||||
appEqualizer.setOnPreferenceClickListener(preference -> {
|
||||
NavController navController = NavHostFragment.findNavController(this);
|
||||
NavOptions navOptions = new NavOptions.Builder()
|
||||
.setLaunchSingleTop(true)
|
||||
.setPopUpTo(R.id.equalizerFragment, true)
|
||||
.build();
|
||||
activity.setBottomNavigationBarVisibility(true);
|
||||
activity.setBottomSheetVisibility(true);
|
||||
navController.navigate(R.id.equalizerFragment, null, navOptions);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (isServiceBound) {
|
||||
requireActivity().unbindService(serviceConnection);
|
||||
isServiceBound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.SongListPageViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@@ -49,6 +50,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||
private FragmentSongListPageBinding bind;
|
||||
private MainActivity activity;
|
||||
private SongListPageViewModel songListPageViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
|
||||
private SongHorizontalAdapter songHorizontalAdapter;
|
||||
|
||||
@@ -69,6 +71,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentSongListPageBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
songListPageViewModel = new ViewModelProvider(requireActivity()).get(SongListPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
initAppBar();
|
||||
@@ -82,6 +85,15 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
initializeMediaBrowser();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setMediaBrowserListenableFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -189,11 +201,14 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
bind.songListRecyclerView.setHasFixedSize(true);
|
||||
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
||||
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
|
||||
setMediaBrowserListenableFuture();
|
||||
reapplyPlayback();
|
||||
songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> {
|
||||
isLoading = false;
|
||||
songHorizontalAdapter.setItems(songs);
|
||||
reapplyPlayback();
|
||||
setSongListPageSubtitle(songs);
|
||||
});
|
||||
|
||||
@@ -325,4 +340,31 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||
public void onMediaLongClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
|
||||
}
|
||||
|
||||
private void observePlayback() {
|
||||
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reapplyPlayback() {
|
||||
if (songHorizontalAdapter != null) {
|
||||
String id = playbackViewModel.getCurrentSongId().getValue();
|
||||
Boolean playing = playbackViewModel.getIsPlaying().getValue();
|
||||
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.MediaItem;
|
||||
@@ -37,6 +38,8 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
@@ -54,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
|
||||
private AlbumID3 album;
|
||||
|
||||
private TextView removeAllTextView;
|
||||
private List<Child> currentAlbumTracks = Collections.emptyList();
|
||||
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
|
||||
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
@Nullable
|
||||
@@ -72,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
@@ -163,7 +176,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
|
||||
downloadAll.setOnClickListener(v -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
|
||||
} else {
|
||||
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||
}
|
||||
dismissBottomSheet();
|
||||
});
|
||||
});
|
||||
@@ -182,19 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
});
|
||||
});
|
||||
|
||||
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
|
||||
removeAllTextView = view.findViewById(R.id.remove_all_text_view);
|
||||
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
|
||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
currentAlbumTracks = songs != null ? songs : Collections.emptyList();
|
||||
currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
|
||||
|
||||
removeAll.setOnClickListener(v -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||
removeAllTextView.setOnClickListener(v -> {
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
List<Download> downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
|
||||
} else {
|
||||
currentAlbumTracks.forEach(ExternalAudioReader::delete);
|
||||
}
|
||||
dismissBottomSheet();
|
||||
});
|
||||
updateRemoveAllVisibility();
|
||||
});
|
||||
|
||||
initDownloadUI(removeAll);
|
||||
|
||||
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
|
||||
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
|
||||
if (artist != null) {
|
||||
@@ -234,14 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
dismiss();
|
||||
}
|
||||
|
||||
private void initDownloadUI(TextView removeAll) {
|
||||
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
|
||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
private void updateRemoveAllVisibility() {
|
||||
if (removeAllTextView == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
|
||||
removeAll.setVisibility(View.VISIBLE);
|
||||
if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
|
||||
removeAllTextView.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
List<MediaItem> mediaItems = currentAlbumMediaItems;
|
||||
if (mediaItems == null || mediaItems.isEmpty()) {
|
||||
removeAllTextView.setVisibility(View.GONE);
|
||||
} else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
|
||||
removeAllTextView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
removeAllTextView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
|
||||
removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeMediaBrowser() {
|
||||
|
||||
@@ -66,7 +66,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
// TODO Utilizzare il viewmodel come tramite ed evitare le chiamate dirette
|
||||
// TODO Use the viewmodel as a conduit and avoid direct calls
|
||||
private void init(View view) {
|
||||
ImageView coverArtist = view.findViewById(R.id.artist_cover_image_view);
|
||||
CustomGlideRequest.Builder
|
||||
@@ -81,7 +81,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
|
||||
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
|
||||
favoriteToggle.setChecked(artistBottomSheetViewModel.getArtist().getStarred() != null);
|
||||
favoriteToggle.setOnClickListener(v -> {
|
||||
artistBottomSheetViewModel.setFavorite();
|
||||
artistBottomSheetViewModel.setFavorite(requireContext());
|
||||
});
|
||||
|
||||
TextView playRadio = view.findViewById(R.id.play_radio_text_view);
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple
|
||||
|
||||
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
|
||||
removeAll.setOnClickListener(v -> {
|
||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||
} else {
|
||||
songs.forEach(ExternalAudioReader::delete);
|
||||
}
|
||||
|
||||
dismissBottomSheet();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
@@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
@@ -39,6 +41,10 @@ import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import android.content.Intent;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
@@ -48,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
private SongBottomSheetViewModel songBottomSheetViewModel;
|
||||
private Child song;
|
||||
|
||||
private TextView downloadButton;
|
||||
private TextView removeButton;
|
||||
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
@Nullable
|
||||
@@ -66,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
@@ -157,25 +172,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
dismissBottomSheet();
|
||||
});
|
||||
|
||||
TextView download = view.findViewById(R.id.download_text_view);
|
||||
download.setOnClickListener(v -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
downloadButton = view.findViewById(R.id.download_text_view);
|
||||
downloadButton.setOnClickListener(v -> {
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
} else {
|
||||
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||
}
|
||||
dismissBottomSheet();
|
||||
});
|
||||
|
||||
TextView remove = view.findViewById(R.id.remove_text_view);
|
||||
remove.setOnClickListener(v -> {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
removeButton = view.findViewById(R.id.remove_text_view);
|
||||
removeButton.setOnClickListener(v -> {
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(
|
||||
MappingUtil.mapDownload(song),
|
||||
new Download(song)
|
||||
);
|
||||
} else {
|
||||
ExternalAudioReader.delete(song);
|
||||
}
|
||||
dismissBottomSheet();
|
||||
});
|
||||
|
||||
initDownloadUI(download, remove);
|
||||
updateDownloadButtons();
|
||||
|
||||
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
|
||||
addToPlaylist.setOnClickListener(v -> {
|
||||
@@ -243,12 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
dismiss();
|
||||
}
|
||||
|
||||
private void initDownloadUI(TextView download, TextView remove) {
|
||||
if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
|
||||
remove.setVisibility(View.VISIBLE);
|
||||
private void updateDownloadButtons() {
|
||||
if (downloadButton == null || removeButton == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
|
||||
downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
|
||||
removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
|
||||
} else {
|
||||
download.setVisibility(View.VISIBLE);
|
||||
remove.setVisibility(View.GONE);
|
||||
boolean hasLocal = ExternalAudioReader.getUri(song) != null;
|
||||
downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
|
||||
removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ object Constants {
|
||||
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
|
||||
|
||||
const val DOWNLOAD_URI = "rest/download"
|
||||
const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
|
||||
const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
|
||||
const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
|
||||
const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
|
||||
const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
|
||||
const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
|
||||
const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
|
||||
|
||||
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
|
||||
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
|
||||
@@ -116,4 +123,13 @@ object Constants {
|
||||
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
|
||||
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
|
||||
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
|
||||
|
||||
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
|
||||
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
|
||||
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
|
||||
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
|
||||
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
|
||||
}
|
||||
@@ -78,32 +78,26 @@ public final class DownloadUtil {
|
||||
return httpDataSourceFactory;
|
||||
}
|
||||
|
||||
public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
|
||||
if (dataSourceFactory == null) {
|
||||
context = context.getApplicationContext();
|
||||
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
|
||||
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
|
||||
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
|
||||
return dataSourceFactory;
|
||||
}
|
||||
|
||||
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
|
||||
|
||||
if (Preferences.getStreamingCacheSize() > 0) {
|
||||
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
|
||||
.setCache(getStreamingCache(context))
|
||||
.setUpstreamDataSourceFactory(upstreamFactory);
|
||||
|
||||
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
|
||||
new StreamingCacheDataSource.Factory(streamCacheFactory),
|
||||
dataSpec -> {
|
||||
DataSpec.Builder builder = dataSpec.buildUpon();
|
||||
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
|
||||
return builder.build();
|
||||
}
|
||||
);
|
||||
|
||||
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
|
||||
} else {
|
||||
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
|
||||
}
|
||||
}
|
||||
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
|
||||
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
|
||||
.setCache(getStreamingCache(context))
|
||||
.setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context));
|
||||
|
||||
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
|
||||
new StreamingCacheDataSource.Factory(streamCacheFactory),
|
||||
dataSpec -> {
|
||||
DataSpec.Builder builder = dataSpec.buildUpon();
|
||||
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
|
||||
return builder.build();
|
||||
}
|
||||
);
|
||||
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
|
||||
return dataSourceFactory;
|
||||
}
|
||||
|
||||
@@ -193,19 +187,21 @@ public final class DownloadUtil {
|
||||
|
||||
private static synchronized File getDownloadDirectory(Context context) {
|
||||
if (downloadDirectory == null) {
|
||||
if (Preferences.getDownloadStoragePreference() == 0) {
|
||||
int pref = Preferences.getDownloadStoragePreference();
|
||||
if (pref == 0) {
|
||||
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||
if (downloadDirectory == null) {
|
||||
downloadDirectory = context.getFilesDir();
|
||||
}
|
||||
} else {
|
||||
} else if (pref == 1) {
|
||||
try {
|
||||
downloadDirectory = context.getExternalFilesDirs(null)[1];
|
||||
} catch (Exception exception) {
|
||||
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||
Preferences.setDownloadStoragePreference(0);
|
||||
}
|
||||
|
||||
} else {
|
||||
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.cappielloantonio.tempo.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
|
||||
@UnstableApi
|
||||
class DynamicMediaSourceFactory(
|
||||
private val context: Context
|
||||
) : MediaSource.Factory {
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
|
||||
|
||||
val streamingCacheSize = Preferences.getStreamingCacheSize()
|
||||
val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
|
||||
|
||||
val useUpstream = when {
|
||||
streamingCacheSize.toInt() == 0 -> true
|
||||
streamingCacheSize > 0 && bypassCache -> true
|
||||
streamingCacheSize > 0 && !bypassCache -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
val dataSourceFactory: DataSource.Factory = if (useUpstream) {
|
||||
DownloadUtil.getUpstreamDataSourceFactory(context)
|
||||
} else {
|
||||
DownloadUtil.getCacheDataSourceFactory(context)
|
||||
}
|
||||
|
||||
return when {
|
||||
mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 ||
|
||||
mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> {
|
||||
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
|
||||
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getSupportedTypes(): IntArray {
|
||||
return intArrayOf(
|
||||
C.CONTENT_TYPE_HLS,
|
||||
C.CONTENT_TYPE_OTHER
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.cappielloantonio.tempo.util;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class ExternalAudioReader {
|
||||
|
||||
private static final Map<String, DocumentFile> cache = new ConcurrentHashMap<>();
|
||||
private static final Object LOCK = new Object();
|
||||
private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final MutableLiveData<Long> refreshEvents = new MutableLiveData<>();
|
||||
|
||||
private static volatile String cachedDirUri;
|
||||
private static volatile boolean refreshInProgress = false;
|
||||
private static volatile boolean refreshQueued = false;
|
||||
|
||||
private static String sanitizeFileName(String name) {
|
||||
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
|
||||
sanitized = sanitized.replaceAll("\\s+", " ").trim();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static String normalizeForComparison(String name) {
|
||||
String s = sanitizeFileName(name);
|
||||
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
|
||||
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
|
||||
return s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static void ensureCache() {
|
||||
String uriString = Preferences.getDownloadDirectoryUri();
|
||||
if (uriString == null) {
|
||||
synchronized (LOCK) {
|
||||
cache.clear();
|
||||
cachedDirUri = null;
|
||||
}
|
||||
ExternalDownloadMetadataStore.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (uriString.equals(cachedDirUri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean runSynchronously = false;
|
||||
synchronized (LOCK) {
|
||||
if (refreshInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
scheduleRefreshLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInProgress = true;
|
||||
runSynchronously = true;
|
||||
}
|
||||
|
||||
if (runSynchronously) {
|
||||
try {
|
||||
rebuildCache();
|
||||
} finally {
|
||||
onRefreshFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void refreshCache() {
|
||||
refreshCacheAsync();
|
||||
}
|
||||
|
||||
public static void refreshCacheAsync() {
|
||||
synchronized (LOCK) {
|
||||
cachedDirUri = null;
|
||||
cache.clear();
|
||||
}
|
||||
requestRefresh();
|
||||
}
|
||||
|
||||
public static LiveData<Long> getRefreshEvents() {
|
||||
return refreshEvents;
|
||||
}
|
||||
|
||||
private static String buildKey(String artist, String title, String album) {
|
||||
String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
|
||||
if (album != null && !album.isEmpty()) name += " (" + album + ")";
|
||||
return normalizeForComparison(name);
|
||||
}
|
||||
|
||||
private static Uri findUri(String artist, String title, String album) {
|
||||
ensureCache();
|
||||
if (cachedDirUri == null) return null;
|
||||
|
||||
DocumentFile file = cache.get(buildKey(artist, title, album));
|
||||
return file != null && file.exists() ? file.getUri() : null;
|
||||
}
|
||||
|
||||
public static Uri getUri(Child media) {
|
||||
return findUri(media.getArtist(), media.getTitle(), media.getAlbum());
|
||||
}
|
||||
|
||||
public static Uri getUri(PodcastEpisode episode) {
|
||||
return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum());
|
||||
}
|
||||
|
||||
public static synchronized void removeMetadata(Child media) {
|
||||
if (media == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
|
||||
cache.remove(key);
|
||||
ExternalDownloadMetadataStore.remove(key);
|
||||
}
|
||||
|
||||
public static boolean delete(Child media) {
|
||||
ensureCache();
|
||||
if (cachedDirUri == null) return false;
|
||||
|
||||
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
|
||||
DocumentFile file = cache.get(key);
|
||||
boolean deleted = false;
|
||||
if (file != null && file.exists()) {
|
||||
deleted = file.delete();
|
||||
}
|
||||
if (deleted) {
|
||||
cache.remove(key);
|
||||
ExternalDownloadMetadataStore.remove(key);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static void requestRefresh() {
|
||||
synchronized (LOCK) {
|
||||
scheduleRefreshLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleRefreshLocked() {
|
||||
if (refreshInProgress) {
|
||||
refreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInProgress = true;
|
||||
REFRESH_EXECUTOR.execute(() -> {
|
||||
try {
|
||||
rebuildCache();
|
||||
} finally {
|
||||
onRefreshFinished();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void rebuildCache() {
|
||||
String uriString = Preferences.getDownloadDirectoryUri();
|
||||
if (uriString == null) {
|
||||
synchronized (LOCK) {
|
||||
cache.clear();
|
||||
cachedDirUri = null;
|
||||
}
|
||||
ExternalDownloadMetadataStore.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
|
||||
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
|
||||
Set<String> verifiedKeys = new HashSet<>();
|
||||
Map<String, DocumentFile> newEntries = new HashMap<>();
|
||||
|
||||
if (directory != null && directory.canRead()) {
|
||||
for (DocumentFile file : directory.listFiles()) {
|
||||
if (file == null || file.isDirectory()) continue;
|
||||
String existing = file.getName();
|
||||
if (existing == null) continue;
|
||||
|
||||
String base = existing.replaceFirst("\\.[^\\.]+$", "");
|
||||
String key = normalizeForComparison(base);
|
||||
Long expected = expectedSizes.get(key);
|
||||
long actualLength = file.length();
|
||||
|
||||
if (expected != null && expected > 0 && actualLength == expected) {
|
||||
newEntries.put(key, file);
|
||||
verifiedKeys.add(key);
|
||||
} else {
|
||||
ExternalDownloadMetadataStore.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!expectedSizes.isEmpty()) {
|
||||
if (verifiedKeys.isEmpty()) {
|
||||
ExternalDownloadMetadataStore.clear();
|
||||
} else {
|
||||
for (String key : expectedSizes.keySet()) {
|
||||
if (!verifiedKeys.contains(key)) {
|
||||
ExternalDownloadMetadataStore.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized (LOCK) {
|
||||
cache.clear();
|
||||
cache.putAll(newEntries);
|
||||
cachedDirUri = uriString;
|
||||
}
|
||||
}
|
||||
|
||||
private static void onRefreshFinished() {
|
||||
boolean runAgain;
|
||||
synchronized (LOCK) {
|
||||
refreshInProgress = false;
|
||||
runAgain = refreshQueued;
|
||||
refreshQueued = false;
|
||||
}
|
||||
|
||||
refreshEvents.postValue(SystemClock.elapsedRealtime());
|
||||
|
||||
if (runAgain) {
|
||||
requestRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.cappielloantonio.tempo.util;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.provider.Settings;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.repository.DownloadRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class ExternalAudioWriter {
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
private static final int CONNECT_TIMEOUT_MS = 15_000;
|
||||
private static final int READ_TIMEOUT_MS = 60_000;
|
||||
|
||||
private ExternalAudioWriter() {
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String name) {
|
||||
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
|
||||
sanitized = sanitized.replaceAll("\\s+", " ").trim();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static String normalizeForComparison(String name) {
|
||||
String s = sanitizeFileName(name);
|
||||
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
|
||||
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
|
||||
return s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static DocumentFile findFile(DocumentFile dir, String fileName) {
|
||||
String normalized = normalizeForComparison(fileName);
|
||||
for (DocumentFile file : dir.listFiles()) {
|
||||
if (file.isDirectory()) continue;
|
||||
String existing = file.getName();
|
||||
if (existing != null && normalizeForComparison(existing).equals(normalized)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void downloadToUserDirectory(Context context, Child child) {
|
||||
if (context == null || child == null) {
|
||||
return;
|
||||
}
|
||||
Context appContext = context.getApplicationContext();
|
||||
MediaItem mediaItem = MappingUtil.mapDownload(child);
|
||||
String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
|
||||
EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
|
||||
}
|
||||
|
||||
private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
|
||||
String uriString = Preferences.getDownloadDirectoryUri();
|
||||
if (uriString == null) {
|
||||
notifyUnavailable(context);
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
|
||||
if (directory == null || !directory.canWrite()) {
|
||||
notifyFailure(context, "Cannot write to folder.");
|
||||
return;
|
||||
}
|
||||
|
||||
String artist = child.getArtist() != null ? child.getArtist() : "";
|
||||
String title = child.getTitle() != null ? child.getTitle() : fallbackName;
|
||||
String album = child.getAlbum() != null ? child.getAlbum() : "";
|
||||
String baseName = artist.isEmpty() ? title : artist + " - " + title;
|
||||
if (!album.isEmpty()) baseName += " (" + album + ")";
|
||||
if (baseName.isEmpty()) {
|
||||
baseName = fallbackName != null ? fallbackName : "download";
|
||||
}
|
||||
String metadataKey = normalizeForComparison(baseName);
|
||||
|
||||
Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
|
||||
? mediaItem.requestMetadata.mediaUri
|
||||
: null;
|
||||
if (mediaUri == null) {
|
||||
notifyFailure(context, "Invalid media URI.");
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
return;
|
||||
}
|
||||
String scheme = mediaUri.getScheme();
|
||||
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
|
||||
notifyFailure(context, "Unsupported media URI.");
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
return;
|
||||
}
|
||||
|
||||
HttpURLConnection connection = null;
|
||||
DocumentFile targetFile = null;
|
||||
try {
|
||||
connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setRequestProperty("Accept-Encoding", "identity");
|
||||
connection.connect();
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
|
||||
notifyFailure(context, "Server returned " + responseCode);
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
return;
|
||||
}
|
||||
|
||||
String mimeType = connection.getContentType();
|
||||
if (mimeType == null || mimeType.isEmpty()) {
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
|
||||
if (extension == null || extension.isEmpty()) {
|
||||
String suffix = child.getSuffix();
|
||||
if (suffix != null && !suffix.isEmpty()) {
|
||||
extension = suffix;
|
||||
} else {
|
||||
extension = "bin";
|
||||
}
|
||||
}
|
||||
|
||||
String sanitized = sanitizeFileName(baseName);
|
||||
if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
|
||||
if (sanitized.isEmpty()) sanitized = "download";
|
||||
String fileName = sanitized + "." + extension;
|
||||
|
||||
DocumentFile existingFile = findFile(directory, fileName);
|
||||
long remoteLength = connection.getContentLengthLong();
|
||||
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
|
||||
if (existingFile != null && existingFile.exists()) {
|
||||
long localLength = existingFile.length();
|
||||
boolean matches = false;
|
||||
if (remoteLength > 0 && localLength == remoteLength) {
|
||||
matches = true;
|
||||
} else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
|
||||
matches = true;
|
||||
}
|
||||
if (matches) {
|
||||
ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
|
||||
recordDownload(child, existingFile.getUri());
|
||||
ExternalAudioReader.refreshCacheAsync();
|
||||
notifyExists(context, fileName);
|
||||
return;
|
||||
} else {
|
||||
existingFile.delete();
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
}
|
||||
}
|
||||
|
||||
targetFile = directory.createFile(mimeType, fileName);
|
||||
if (targetFile == null) {
|
||||
notifyFailure(context, "Failed to create file.");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri targetUri = targetFile.getUri();
|
||||
try (InputStream in = connection.getInputStream();
|
||||
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
|
||||
if (out == null) {
|
||||
notifyFailure(context, "Cannot open output stream.");
|
||||
targetFile.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int len;
|
||||
long total = 0;
|
||||
while ((len = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, len);
|
||||
total += len;
|
||||
}
|
||||
out.flush();
|
||||
|
||||
if (total <= 0) {
|
||||
targetFile.delete();
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
notifyFailure(context, "Empty download.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteLength > 0 && total != remoteLength) {
|
||||
targetFile.delete();
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
notifyFailure(context, "Incomplete download.");
|
||||
return;
|
||||
}
|
||||
|
||||
ExternalDownloadMetadataStore.recordSize(metadataKey, total);
|
||||
recordDownload(child, targetUri);
|
||||
notifySuccess(context, fileName, child, targetUri);
|
||||
ExternalAudioReader.refreshCacheAsync();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (targetFile != null) {
|
||||
targetFile.delete();
|
||||
}
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void notifyUnavailable(Context context) {
|
||||
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.getPackageName(), null));
|
||||
PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("No download folder set")
|
||||
.setContentText("Tap to set one in settings")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setSilent(true)
|
||||
.setContentIntent(openSettings)
|
||||
.setAutoCancel(true);
|
||||
|
||||
manager.notify(1011, builder.build());
|
||||
}
|
||||
|
||||
private static void notifyFailure(Context context, String message) {
|
||||
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("Download failed")
|
||||
.setContentText(message)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true);
|
||||
manager.notify((int) System.currentTimeMillis(), builder.build());
|
||||
}
|
||||
|
||||
private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
|
||||
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("Download complete")
|
||||
.setContentText(name)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setAutoCancel(true);
|
||||
|
||||
PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
|
||||
if (playIntent != null) {
|
||||
builder.setContentIntent(playIntent);
|
||||
}
|
||||
|
||||
manager.notify((int) System.currentTimeMillis(), builder.build());
|
||||
}
|
||||
|
||||
private static void recordDownload(Child child, Uri fileUri) {
|
||||
if (child == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Download download = new Download(child);
|
||||
download.setDownloadState(1);
|
||||
if (fileUri != null) {
|
||||
download.setDownloadUri(fileUri.toString());
|
||||
}
|
||||
|
||||
new DownloadRepository().insert(download);
|
||||
}
|
||||
|
||||
private static void notifyExists(Context context, String name) {
|
||||
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("Already downloaded")
|
||||
.setContentText(name)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setAutoCancel(true);
|
||||
manager.notify((int) System.currentTimeMillis(), builder.build());
|
||||
}
|
||||
|
||||
private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
|
||||
if (fileUri == null) return null;
|
||||
Intent intent = new Intent(context, MainActivity.class)
|
||||
.setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
|
||||
.putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
|
||||
int requestCode;
|
||||
if (child.getId() != null) {
|
||||
requestCode = Math.abs(child.getId().hashCode());
|
||||
} else {
|
||||
requestCode = Math.abs(fileUri.toString().hashCode());
|
||||
}
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.cappielloantonio.tempo.util;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ExternalDownloadMetadataStore {
|
||||
|
||||
private static final String PREF_KEY = "external_download_metadata";
|
||||
|
||||
private ExternalDownloadMetadataStore() {
|
||||
}
|
||||
|
||||
private static SharedPreferences preferences() {
|
||||
return App.getInstance().getPreferences();
|
||||
}
|
||||
|
||||
private static JSONObject readAll() {
|
||||
String raw = preferences().getString(PREF_KEY, "{}");
|
||||
try {
|
||||
return new JSONObject(raw);
|
||||
} catch (JSONException e) {
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeAll(JSONObject object) {
|
||||
preferences().edit().putString(PREF_KEY, object.toString()).apply();
|
||||
}
|
||||
|
||||
public static synchronized void clear() {
|
||||
writeAll(new JSONObject());
|
||||
}
|
||||
|
||||
public static synchronized void recordSize(String key, long size) {
|
||||
if (key == null || size <= 0) {
|
||||
return;
|
||||
}
|
||||
JSONObject object = readAll();
|
||||
try {
|
||||
object.put(key, size);
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
writeAll(object);
|
||||
}
|
||||
|
||||
public static synchronized void remove(String key) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
JSONObject object = readAll();
|
||||
object.remove(key);
|
||||
writeAll(object);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static synchronized Long getSize(String key) {
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject object = readAll();
|
||||
if (!object.has(key)) {
|
||||
return null;
|
||||
}
|
||||
long size = object.optLong(key, -1L);
|
||||
return size > 0 ? size : null;
|
||||
}
|
||||
|
||||
public static synchronized Map<String, Long> snapshot() {
|
||||
JSONObject object = readAll();
|
||||
if (object.length() == 0) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, Long> sizes = new HashMap<>();
|
||||
Iterator<String> keys = object.keys();
|
||||
while (keys.hasNext()) {
|
||||
String key = keys.next();
|
||||
long size = object.optLong(key, -1L);
|
||||
if (size > 0) {
|
||||
sizes.put(key, size);
|
||||
}
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
public static synchronized void retainOnly(Set<String> keysToKeep) {
|
||||
if (keysToKeep == null || keysToKeep.isEmpty()) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
JSONObject object = readAll();
|
||||
if (object.length() == 0) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
Iterator<String> iterator = object.keys();
|
||||
while (iterator.hasNext()) {
|
||||
keys.add(iterator.next());
|
||||
}
|
||||
boolean changed = false;
|
||||
for (String key : keys) {
|
||||
if (!keysToKeep.contains(key)) {
|
||||
object.remove(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
writeAll(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.HeartRating;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
@@ -16,6 +18,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -83,6 +86,13 @@ public class MappingUtil {
|
||||
.setAlbumTitle(media.getAlbum())
|
||||
.setArtist(media.getArtist())
|
||||
.setArtworkUri(artworkUri)
|
||||
.setUserRating(new HeartRating(media.getStarred() != null))
|
||||
.setSupportedCommands(
|
||||
ImmutableList.of(
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
|
||||
)
|
||||
)
|
||||
.setExtras(bundle)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
@@ -110,6 +120,11 @@ public class MappingUtil {
|
||||
}
|
||||
|
||||
public static MediaItem mapDownload(Child media) {
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
|
||||
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
|
||||
|
||||
return new MediaItem.Builder()
|
||||
.setMediaId(media.getId())
|
||||
.setMediaMetadata(
|
||||
@@ -120,12 +135,14 @@ public class MappingUtil {
|
||||
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
|
||||
.setAlbumTitle(media.getAlbum())
|
||||
.setArtist(media.getArtist())
|
||||
.setExtras(bundle)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.build()
|
||||
)
|
||||
.setRequestMetadata(
|
||||
new MediaItem.RequestMetadata.Builder()
|
||||
.setExtras(bundle)
|
||||
.setMediaUri(Preferences.preferTranscodedDownload() ? MusicUtil.getTranscodedDownloadUri(media.getId()) : MusicUtil.getDownloadUri(media.getId()))
|
||||
.build()
|
||||
)
|
||||
@@ -217,12 +234,20 @@ public class MappingUtil {
|
||||
}
|
||||
|
||||
private static Uri getUri(Child media) {
|
||||
if (Preferences.getDownloadDirectoryUri() != null) {
|
||||
Uri local = ExternalAudioReader.getUri(media);
|
||||
return local != null ? local : MusicUtil.getStreamUri(media.getId());
|
||||
}
|
||||
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
|
||||
? getDownloadUri(media.getId())
|
||||
: MusicUtil.getStreamUri(media.getId());
|
||||
}
|
||||
|
||||
private static Uri getUri(PodcastEpisode podcastEpisode) {
|
||||
if (Preferences.getDownloadDirectoryUri() != null) {
|
||||
Uri local = ExternalAudioReader.getUri(podcastEpisode);
|
||||
return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
|
||||
}
|
||||
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
|
||||
? getDownloadUri(podcastEpisode.getStreamId())
|
||||
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
|
||||
@@ -232,4 +257,11 @@ public class MappingUtil {
|
||||
Download download = new DownloadRepository().getDownload(id);
|
||||
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
|
||||
}
|
||||
|
||||
public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
|
||||
if (owner == null || onRefresh == null) {
|
||||
return;
|
||||
}
|
||||
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ object Preferences {
|
||||
private const val WIFI_ONLY = "wifi_only"
|
||||
private const val DATA_SAVING_MODE = "data_saving_mode"
|
||||
private const val SERVER_UNREACHABLE = "server_unreachable"
|
||||
private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use"
|
||||
private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use"
|
||||
private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use"
|
||||
private const val QUEUE_SYNCING = "queue_syncing"
|
||||
@@ -45,11 +46,13 @@ object Preferences {
|
||||
private const val ROUNDED_CORNER_SIZE = "rounded_corner_size"
|
||||
private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility"
|
||||
private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility"
|
||||
private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics"
|
||||
private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility"
|
||||
private const val REPLAY_GAIN_MODE = "replay_gain_mode"
|
||||
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
|
||||
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
|
||||
private const val DOWNLOAD_STORAGE = "download_storage"
|
||||
private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri"
|
||||
private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
|
||||
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
|
||||
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
|
||||
@@ -69,7 +72,10 @@ object Preferences {
|
||||
private const val NEXT_UPDATE_CHECK = "next_update_check"
|
||||
private const val CONTINUOUS_PLAY = "continuous_play"
|
||||
private const val LAST_INSTANT_MIX = "last_instant_mix"
|
||||
|
||||
private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
|
||||
private const val EQUALIZER_ENABLED = "equalizer_enabled"
|
||||
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
|
||||
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
|
||||
|
||||
@JvmStatic
|
||||
fun getServer(): String? {
|
||||
@@ -161,6 +167,24 @@ object Preferences {
|
||||
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isAutoDownloadLyricsEnabled(): Boolean {
|
||||
val preferences = App.getInstance().preferences
|
||||
|
||||
if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
|
||||
return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
|
||||
App.getInstance().preferences.edit()
|
||||
.putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLocalAddress(): String? {
|
||||
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
|
||||
@@ -302,6 +326,18 @@ object Preferences {
|
||||
.apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isStarredArtistsSyncEnabled(): Boolean {
|
||||
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) {
|
||||
App.getInstance().preferences.edit().putBoolean(
|
||||
SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled
|
||||
).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isStarredAlbumsSyncEnabled(): Boolean {
|
||||
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false)
|
||||
@@ -326,6 +362,16 @@ object Preferences {
|
||||
).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showShuffleInsteadOfHeart(): Boolean {
|
||||
return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setShuffleInsteadOfHeart(enabled: Boolean) {
|
||||
App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showServerUnreachableDialog(): Boolean {
|
||||
return App.getInstance().preferences.getLong(
|
||||
@@ -419,6 +465,20 @@ object Preferences {
|
||||
).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getDownloadDirectoryUri(): String? {
|
||||
return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setDownloadDirectoryUri(uri: String?) {
|
||||
val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
|
||||
if (current != uri) {
|
||||
ExternalDownloadMetadataStore.clear()
|
||||
}
|
||||
App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getDefaultDownloadViewType(): String {
|
||||
return App.getInstance().preferences.getString(
|
||||
@@ -538,4 +598,44 @@ object Preferences {
|
||||
LAST_INSTANT_MIX, 0
|
||||
) + 5000 < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setAllowPlaylistDuplicates(allowDuplicates: Boolean) {
|
||||
return App.getInstance().preferences.edit().putString(
|
||||
ALLOW_PLAYLIST_DUPLICATES,
|
||||
allowDuplicates.toString()
|
||||
).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun allowPlaylistDuplicates(): Boolean {
|
||||
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setEqualizerEnabled(enabled: Boolean) {
|
||||
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isEqualizerEnabled(): Boolean {
|
||||
return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setEqualizerBandLevels(bandLevels: ShortArray) {
|
||||
val asString = bandLevels.joinToString(",")
|
||||
App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getEqualizerBandLevels(bandCount: Short): ShortArray {
|
||||
val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
|
||||
if (str.isNullOrBlank()) {
|
||||
return ShortArray(bandCount.toInt())
|
||||
}
|
||||
val parts = str.split(",")
|
||||
if (parts.size < bandCount) return ShortArray(bandCount.toInt())
|
||||
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.interfaces.StarCallback;
|
||||
import com.cappielloantonio.tempo.repository.ArtistRepository;
|
||||
import com.cappielloantonio.tempo.repository.FavoriteRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.util.NetworkUtil;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
|
||||
public class ArtistBottomSheetViewModel extends AndroidViewModel {
|
||||
private final ArtistRepository artistRepository;
|
||||
@@ -34,7 +42,7 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
|
||||
this.artist = artist;
|
||||
}
|
||||
|
||||
public void setFavorite() {
|
||||
public void setFavorite(Context context) {
|
||||
if (artist.getStarred() != null) {
|
||||
if (NetworkUtil.isOffline()) {
|
||||
removeFavoriteOffline();
|
||||
@@ -43,9 +51,9 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
|
||||
}
|
||||
} else {
|
||||
if (NetworkUtil.isOffline()) {
|
||||
setFavoriteOffline();
|
||||
setFavoriteOffline(context);
|
||||
} else {
|
||||
setFavoriteOnline();
|
||||
setFavoriteOnline(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +67,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
|
||||
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
|
||||
@Override
|
||||
public void onError() {
|
||||
// artist.setStarred(new Date());
|
||||
favoriteRepository.starLater(null, null, artist.getId(), false);
|
||||
}
|
||||
});
|
||||
@@ -67,20 +74,45 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
|
||||
artist.setStarred(null);
|
||||
}
|
||||
|
||||
private void setFavoriteOffline() {
|
||||
private void setFavoriteOffline(Context context) {
|
||||
favoriteRepository.starLater(null, null, artist.getId(), true);
|
||||
artist.setStarred(new Date());
|
||||
}
|
||||
|
||||
private void setFavoriteOnline() {
|
||||
private void setFavoriteOnline(Context context) {
|
||||
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
|
||||
@Override
|
||||
public void onError() {
|
||||
// artist.setStarred(null);
|
||||
favoriteRepository.starLater(null, null, artist.getId(), true);
|
||||
}
|
||||
});
|
||||
|
||||
artist.setStarred(new Date());
|
||||
|
||||
Log.d("ArtistSync", "Checking preference: " + Preferences.isStarredArtistsSyncEnabled());
|
||||
|
||||
if (Preferences.isStarredArtistsSyncEnabled()) {
|
||||
Log.d("ArtistSync", "Starting artist sync for: " + artist.getName());
|
||||
|
||||
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
|
||||
@Override
|
||||
public void onSongsCollected(List<Child> songs) {
|
||||
Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0));
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
Log.d("ArtistSync", "Starting download of " + songs.size() + " songs");
|
||||
DownloadUtil.getDownloadTracker(context).download(
|
||||
MappingUtil.mapDownloads(songs),
|
||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||
);
|
||||
Log.d("ArtistSync", "Download started successfully");
|
||||
} else {
|
||||
Log.d("ArtistSync", "No songs to download");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Log.d("ArtistSync", "Artist sync preference is disabled");
|
||||
}
|
||||
}
|
||||
///
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import android.app.Application;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.model.DownloadStack;
|
||||
import com.cappielloantonio.tempo.repository.DownloadRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||
|
||||
private final MutableLiveData<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Integer> refreshResult = new MutableLiveData<>();
|
||||
|
||||
public DownloadViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
@@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||
return viewStack;
|
||||
}
|
||||
|
||||
public LiveData<Integer> getRefreshResult() {
|
||||
return refreshResult;
|
||||
}
|
||||
|
||||
public void initViewStack(DownloadStack level) {
|
||||
ArrayList<DownloadStack> stack = new ArrayList<>();
|
||||
stack.add(level);
|
||||
@@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||
stack.remove(stack.size() - 1);
|
||||
viewStack.setValue(stack);
|
||||
}
|
||||
|
||||
public void refreshExternalDownloads() {
|
||||
new Thread(() -> {
|
||||
String directoryUri = Preferences.getDownloadDirectoryUri();
|
||||
if (directoryUri == null) {
|
||||
refreshResult.postValue(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Download> downloads = downloadRepository.getAllDownloads();
|
||||
if (downloads == null || downloads.isEmpty()) {
|
||||
refreshResult.postValue(0);
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayList<Download> toRemove = new ArrayList<>();
|
||||
|
||||
for (Download download : downloads) {
|
||||
String uriString = download.getDownloadUri();
|
||||
if (uriString == null || uriString.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Uri uri = Uri.parse(uriString);
|
||||
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DocumentFile file;
|
||||
try {
|
||||
file = DocumentFile.fromSingleUri(getApplication(), uri);
|
||||
} catch (SecurityException exception) {
|
||||
file = null;
|
||||
}
|
||||
|
||||
if (file == null || !file.exists()) {
|
||||
toRemove.add(download);
|
||||
}
|
||||
}
|
||||
|
||||
if (!toRemove.isEmpty()) {
|
||||
ArrayList<String> ids = new ArrayList<>();
|
||||
for (Download download : toRemove) {
|
||||
ids.add(download.getId());
|
||||
ExternalAudioReader.removeMetadata(download);
|
||||
}
|
||||
|
||||
downloadRepository.delete(ids);
|
||||
ExternalAudioReader.refreshCache();
|
||||
refreshResult.postValue(ids.size());
|
||||
} else {
|
||||
refreshResult.postValue(0);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public class HomeViewModel extends AndroidViewModel {
|
||||
private final SharingRepository sharingRepository;
|
||||
|
||||
private final StarredAlbumsSyncViewModel albumsSyncViewModel;
|
||||
private final StarredArtistsSyncViewModel artistSyncViewModel;
|
||||
|
||||
private final MutableLiveData<List<Child>> dicoverSongSample = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<AlbumID3>> newReleasedAlbum = new MutableLiveData<>(null);
|
||||
@@ -85,6 +86,7 @@ public class HomeViewModel extends AndroidViewModel {
|
||||
sharingRepository = new SharingRepository();
|
||||
|
||||
albumsSyncViewModel = new StarredAlbumsSyncViewModel(application);
|
||||
artistSyncViewModel = new StarredArtistsSyncViewModel(application);
|
||||
|
||||
setOfflineFavorite();
|
||||
}
|
||||
@@ -174,6 +176,10 @@ public class HomeViewModel extends AndroidViewModel {
|
||||
return albumsSyncViewModel.getAllStarredAlbumSongs();
|
||||
}
|
||||
|
||||
public LiveData<List<Child>> getAllStarredArtistSongs() {
|
||||
return artistSyncViewModel.getAllStarredArtistSongs();
|
||||
}
|
||||
|
||||
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
|
||||
if (starredArtists.getValue() == null) {
|
||||
artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class PlaybackViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<String> currentSongId = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Boolean> isPlaying = new MutableLiveData<>(false);
|
||||
|
||||
public LiveData<String> getCurrentSongId() {
|
||||
return currentSongId;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getIsPlaying() {
|
||||
return isPlaying;
|
||||
}
|
||||
|
||||
public void update(String songId, boolean playing) {
|
||||
if (!Objects.equals(currentSongId.getValue(), songId)) {
|
||||
currentSongId.postValue(songId);
|
||||
}
|
||||
if (!Objects.equals(isPlaying.getValue(), playing)) {
|
||||
isPlaying.postValue(playing);
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
currentSongId.postValue(null);
|
||||
isPlaying.postValue(false);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
@@ -9,14 +10,17 @@ import androidx.lifecycle.AndroidViewModel;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.interfaces.StarCallback;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
import com.cappielloantonio.tempo.model.Queue;
|
||||
import com.cappielloantonio.tempo.repository.AlbumRepository;
|
||||
import com.cappielloantonio.tempo.repository.ArtistRepository;
|
||||
import com.cappielloantonio.tempo.repository.FavoriteRepository;
|
||||
import com.cappielloantonio.tempo.repository.LyricsRepository;
|
||||
import com.cappielloantonio.tempo.repository.OpenRepository;
|
||||
import com.cappielloantonio.tempo.repository.QueueRepository;
|
||||
import com.cappielloantonio.tempo.repository.SongRepository;
|
||||
@@ -31,6 +35,7 @@ import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.NetworkUtil;
|
||||
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
@@ -47,14 +52,20 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
private final QueueRepository queueRepository;
|
||||
private final FavoriteRepository favoriteRepository;
|
||||
private final OpenRepository openRepository;
|
||||
private final LyricsRepository lyricsRepository;
|
||||
private final MutableLiveData<String> lyricsLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<LyricsList> lyricsListLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Boolean> lyricsCachedLiveData = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<String> descriptionLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Child> liveMedia = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<AlbumID3> liveAlbum = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<ArtistID3> liveArtist = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(null);
|
||||
private final Gson gson = new Gson();
|
||||
private boolean lyricsSyncState = true;
|
||||
private LiveData<LyricsCache> cachedLyricsSource;
|
||||
private String currentSongId;
|
||||
private final Observer<LyricsCache> cachedLyricsObserver = this::onCachedLyricsChanged;
|
||||
|
||||
|
||||
public PlayerBottomSheetViewModel(@NonNull Application application) {
|
||||
@@ -66,6 +77,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
queueRepository = new QueueRepository();
|
||||
favoriteRepository = new FavoriteRepository();
|
||||
openRepository = new OpenRepository();
|
||||
lyricsRepository = new LyricsRepository();
|
||||
}
|
||||
|
||||
public LiveData<List<Queue>> getQueueSong() {
|
||||
@@ -122,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
|
||||
media.setStarred(new Date());
|
||||
|
||||
if (Preferences.isStarredSyncEnabled()) {
|
||||
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(context).download(
|
||||
MappingUtil.mapDownload(media),
|
||||
new Download(media)
|
||||
@@ -139,12 +151,49 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
}
|
||||
|
||||
public void refreshMediaInfo(LifecycleOwner owner, Child media) {
|
||||
lyricsLiveData.postValue(null);
|
||||
lyricsListLiveData.postValue(null);
|
||||
lyricsCachedLiveData.postValue(false);
|
||||
|
||||
clearCachedLyricsObserver();
|
||||
|
||||
String songId = media != null ? media.getId() : currentSongId;
|
||||
|
||||
if (TextUtils.isEmpty(songId) || owner == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSongId = songId;
|
||||
|
||||
observeCachedLyrics(owner, songId);
|
||||
|
||||
LyricsCache cachedLyrics = lyricsRepository.getLyrics(songId);
|
||||
if (cachedLyrics != null) {
|
||||
onCachedLyricsChanged(cachedLyrics);
|
||||
}
|
||||
|
||||
if (NetworkUtil.isOffline() || media == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
|
||||
openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsListLiveData::postValue);
|
||||
lyricsLiveData.postValue(null);
|
||||
openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> {
|
||||
lyricsListLiveData.postValue(lyricsList);
|
||||
lyricsLiveData.postValue(null);
|
||||
|
||||
if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) {
|
||||
saveLyricsToCache(media, null, lyricsList);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
songRepository.getSongLyrics(media).observe(owner, lyricsLiveData::postValue);
|
||||
lyricsListLiveData.postValue(null);
|
||||
songRepository.getSongLyrics(media).observe(owner, lyrics -> {
|
||||
lyricsLiveData.postValue(lyrics);
|
||||
lyricsListLiveData.postValue(null);
|
||||
|
||||
if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) {
|
||||
saveLyricsToCache(media, lyrics, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +202,17 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
}
|
||||
|
||||
public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) {
|
||||
currentSongId = mediaId;
|
||||
|
||||
if (!TextUtils.isEmpty(mediaId)) {
|
||||
refreshMediaInfo(owner, null);
|
||||
} else {
|
||||
clearCachedLyricsObserver();
|
||||
lyricsLiveData.postValue(null);
|
||||
lyricsListLiveData.postValue(null);
|
||||
lyricsCachedLiveData.postValue(false);
|
||||
}
|
||||
|
||||
if (mediaType != null) {
|
||||
switch (mediaType) {
|
||||
case Constants.MEDIA_TYPE_MUSIC:
|
||||
@@ -162,7 +222,12 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
case Constants.MEDIA_TYPE_PODCAST:
|
||||
liveMedia.postValue(null);
|
||||
break;
|
||||
default:
|
||||
liveMedia.postValue(null);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
liveMedia.postValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +298,105 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
|
||||
if (TextUtils.isEmpty(songId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cachedLyricsSource = lyricsRepository.observeLyrics(songId);
|
||||
cachedLyricsSource.observe(owner, cachedLyricsObserver);
|
||||
}
|
||||
|
||||
private void clearCachedLyricsObserver() {
|
||||
if (cachedLyricsSource != null) {
|
||||
cachedLyricsSource.removeObserver(cachedLyricsObserver);
|
||||
cachedLyricsSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void onCachedLyricsChanged(LyricsCache lyricsCache) {
|
||||
if (lyricsCache == null) {
|
||||
lyricsCachedLiveData.postValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
lyricsCachedLiveData.postValue(true);
|
||||
|
||||
if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) {
|
||||
try {
|
||||
LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class);
|
||||
lyricsListLiveData.postValue(cachedList);
|
||||
lyricsLiveData.postValue(null);
|
||||
} catch (Exception exception) {
|
||||
lyricsListLiveData.postValue(null);
|
||||
lyricsLiveData.postValue(lyricsCache.getLyrics());
|
||||
}
|
||||
} else {
|
||||
lyricsListLiveData.postValue(null);
|
||||
lyricsLiveData.postValue(lyricsCache.getLyrics());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) {
|
||||
if (media == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LyricsCache lyricsCache = new LyricsCache(media.getId());
|
||||
lyricsCache.setArtist(media.getArtist());
|
||||
lyricsCache.setTitle(media.getTitle());
|
||||
lyricsCache.setUpdatedAt(System.currentTimeMillis());
|
||||
|
||||
if (lyricsList != null && hasStructuredLyrics(lyricsList)) {
|
||||
lyricsCache.setStructuredLyrics(gson.toJson(lyricsList));
|
||||
lyricsCache.setLyrics(null);
|
||||
} else {
|
||||
lyricsCache.setLyrics(lyrics);
|
||||
lyricsCache.setStructuredLyrics(null);
|
||||
}
|
||||
|
||||
lyricsRepository.insert(lyricsCache);
|
||||
lyricsCachedLiveData.postValue(true);
|
||||
}
|
||||
|
||||
private boolean hasStructuredLyrics(LyricsList lyricsList) {
|
||||
return lyricsList != null
|
||||
&& lyricsList.getStructuredLyrics() != null
|
||||
&& !lyricsList.getStructuredLyrics().isEmpty()
|
||||
&& lyricsList.getStructuredLyrics().get(0) != null
|
||||
&& lyricsList.getStructuredLyrics().get(0).getLine() != null
|
||||
&& !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
|
||||
}
|
||||
|
||||
private boolean shouldAutoDownloadLyrics() {
|
||||
return Preferences.isAutoDownloadLyricsEnabled();
|
||||
}
|
||||
|
||||
public boolean downloadCurrentLyrics() {
|
||||
Child media = getLiveMedia().getValue();
|
||||
if (media == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LyricsList lyricsList = lyricsListLiveData.getValue();
|
||||
String lyrics = lyricsLiveData.getValue();
|
||||
|
||||
if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
saveLyricsToCache(media, lyrics, lyricsList);
|
||||
return true;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getLyricsCachedState() {
|
||||
return lyricsCachedLiveData;
|
||||
}
|
||||
|
||||
public void changeSyncLyricsState() {
|
||||
lyricsSyncState = !lyricsSyncState;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.Dialog;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
@@ -11,10 +13,10 @@ import androidx.lifecycle.MutableLiveData;
|
||||
import com.cappielloantonio.tempo.repository.PlaylistRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class PlaylistChooserViewModel extends AndroidViewModel {
|
||||
@@ -34,8 +36,21 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
|
||||
return playlists;
|
||||
}
|
||||
|
||||
public void addSongsToPlaylist(String playlistId) {
|
||||
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(Lists.transform(toAdd, Child::getId)));
|
||||
public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
|
||||
List<String> songIds = Lists.transform(toAdd, Child::getId);
|
||||
if (Preferences.allowPlaylistDuplicates()) {
|
||||
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
|
||||
dialog.dismiss();
|
||||
} else {
|
||||
playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
|
||||
if (playlistSongs != null) {
|
||||
List<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId);
|
||||
songIds.removeAll(playlistSongIds);
|
||||
}
|
||||
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void setSongsToAdd(ArrayList<Child> songs) {
|
||||
|
||||
@@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
|
||||
|
||||
media.setStarred(new Date());
|
||||
|
||||
if (Preferences.isStarredSyncEnabled()) {
|
||||
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloadUtil.getDownloadTracker(context).download(
|
||||
MappingUtil.mapDownload(media),
|
||||
new Download(media)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.cappielloantonio.tempo.viewmodel;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.Activity;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.cappielloantonio.tempo.repository.ArtistRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class StarredArtistsSyncViewModel extends AndroidViewModel {
|
||||
private final ArtistRepository artistRepository;
|
||||
|
||||
private final MutableLiveData<List<ArtistID3>> starredArtists = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<Child>> starredArtistSongs = new MutableLiveData<>(null);
|
||||
|
||||
public StarredArtistsSyncViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
artistRepository = new ArtistRepository();
|
||||
}
|
||||
|
||||
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
|
||||
artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue);
|
||||
return starredArtists;
|
||||
}
|
||||
|
||||
public LiveData<List<Child>> getAllStarredArtistSongs() {
|
||||
artistRepository.getStarredArtists(false, -1).observeForever(new Observer<List<ArtistID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<ArtistID3> artists) {
|
||||
if (artists != null && !artists.isEmpty()) {
|
||||
collectAllArtistSongs(artists, starredArtistSongs::postValue);
|
||||
} else {
|
||||
starredArtistSongs.postValue(new ArrayList<>());
|
||||
}
|
||||
artistRepository.getStarredArtists(false, -1).removeObserver(this);
|
||||
}
|
||||
});
|
||||
|
||||
return starredArtistSongs;
|
||||
}
|
||||
|
||||
public LiveData<List<Child>> getStarredArtistSongs(Activity activity) {
|
||||
artistRepository.getStarredArtists(false, -1).observe((LifecycleOwner) activity, artists -> {
|
||||
if (artists != null && !artists.isEmpty()) {
|
||||
collectAllArtistSongs(artists, starredArtistSongs::postValue);
|
||||
} else {
|
||||
starredArtistSongs.postValue(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
return starredArtistSongs;
|
||||
}
|
||||
|
||||
private void collectAllArtistSongs(List<ArtistID3> artists, ArtistSongsCallback callback) {
|
||||
if (artists == null || artists.isEmpty()) {
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
AtomicInteger remainingArtists = new AtomicInteger(artists.size());
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
|
||||
@Override
|
||||
public void onSongsCollected(List<Child> songs) {
|
||||
if (songs != null) {
|
||||
allSongs.addAll(songs);
|
||||
}
|
||||
|
||||
int remaining = remainingArtists.decrementAndGet();
|
||||
if (remaining == 0) {
|
||||
callback.onSongsCollected(allSongs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private interface ArtistSongsCallback {
|
||||
void onSongsCollected(List<Child> songs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.cappielloantonio.tempo.widget;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public final class WidgetActions {
|
||||
public static void dispatchToMediaSession(Context ctx, String action) {
|
||||
Log.d("TempoWidget", "dispatch action=" + action);
|
||||
Context appCtx = ctx.getApplicationContext();
|
||||
SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
|
||||
ListenableFuture<MediaController> future = new MediaController.Builder(appCtx, token).buildAsync();
|
||||
future.addListener(() -> {
|
||||
try {
|
||||
if (!future.isDone()) return;
|
||||
MediaController c = future.get();
|
||||
Log.d("TempoWidget", "controller connected, isPlaying=" + c.isPlaying());
|
||||
switch (action) {
|
||||
case WidgetProvider.ACT_PLAY_PAUSE:
|
||||
if (c.isPlaying()) c.pause();
|
||||
else c.play();
|
||||
break;
|
||||
case WidgetProvider.ACT_NEXT:
|
||||
c.seekToNext();
|
||||
break;
|
||||
case WidgetProvider.ACT_PREV:
|
||||
c.seekToPrevious();
|
||||
break;
|
||||
case WidgetProvider.ACT_TOGGLE_SHUFFLE:
|
||||
c.setShuffleModeEnabled(!c.getShuffleModeEnabled());
|
||||
break;
|
||||
case WidgetProvider.ACT_CYCLE_REPEAT:
|
||||
int repeatMode = c.getRepeatMode();
|
||||
int nextMode;
|
||||
if (repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
nextMode = Player.REPEAT_MODE_ALL;
|
||||
} else if (repeatMode == Player.REPEAT_MODE_ALL) {
|
||||
nextMode = Player.REPEAT_MODE_ONE;
|
||||
} else {
|
||||
nextMode = Player.REPEAT_MODE_OFF;
|
||||
}
|
||||
c.setRepeatMode(nextMode);
|
||||
break;
|
||||
}
|
||||
WidgetUpdateManager.refreshFromController(ctx);
|
||||
c.release();
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.e("TempoWidget", "dispatch failed", e);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.cappielloantonio.tempo.widget;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
|
||||
import android.app.TaskStackBuilder;
|
||||
import android.app.PendingIntent;
|
||||
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
public class WidgetProvider extends AppWidgetProvider {
|
||||
private static final String TAG = "TempoWidget";
|
||||
public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE";
|
||||
public static final String ACT_NEXT = "tempo.widget.NEXT";
|
||||
public static final String ACT_PREV = "tempo.widget.PREV";
|
||||
public static final String ACT_TOGGLE_SHUFFLE = "tempo.widget.SHUFFLE";
|
||||
public static final String ACT_CYCLE_REPEAT = "tempo.widget.REPEAT";
|
||||
|
||||
@Override
|
||||
public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
|
||||
for (int id : ids) {
|
||||
RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
|
||||
attachIntents(ctx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context ctx, Intent intent) {
|
||||
super.onReceive(ctx, intent);
|
||||
String a = intent.getAction();
|
||||
Log.d(TAG, "onReceive action=" + a);
|
||||
if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)
|
||||
|| ACT_TOGGLE_SHUFFLE.equals(a) || ACT_CYCLE_REPEAT.equals(a)) {
|
||||
WidgetActions.dispatchToMediaSession(ctx, a);
|
||||
} else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) {
|
||||
WidgetUpdateManager.refreshFromController(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||
RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
|
||||
attachIntents(context, rv, appWidgetId);
|
||||
appWidgetManager.updateAppWidget(appWidgetId, rv);
|
||||
WidgetUpdateManager.refreshFromController(context);
|
||||
}
|
||||
|
||||
public static void attachIntents(Context ctx, RemoteViews rv) {
|
||||
attachIntents(ctx, rv, 0);
|
||||
}
|
||||
|
||||
public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) {
|
||||
PendingIntent playPause = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 0,
|
||||
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PLAY_PAUSE),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
);
|
||||
PendingIntent next = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 1,
|
||||
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_NEXT),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
);
|
||||
PendingIntent prev = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 2,
|
||||
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
);
|
||||
PendingIntent shuffle = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 3,
|
||||
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_TOGGLE_SHUFFLE),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
);
|
||||
PendingIntent repeat = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 4,
|
||||
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_CYCLE_REPEAT),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
);
|
||||
|
||||
rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause);
|
||||
rv.setOnClickPendingIntent(R.id.btn_next, next);
|
||||
rv.setOnClickPendingIntent(R.id.btn_prev, prev);
|
||||
rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
|
||||
rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
|
||||
|
||||
PendingIntent launch = TaskStackBuilder.create(ctx)
|
||||
.addNextIntentWithParentStack(new Intent(ctx, MainActivity.class))
|
||||
.getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
rv.setOnClickPendingIntent(R.id.root, launch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cappielloantonio.tempo.widget;
|
||||
|
||||
/**
|
||||
* AppWidget provider entry for the 4x1 widget card. Inherits all behavior
|
||||
* from {@link WidgetProvider}.
|
||||
*/
|
||||
public class WidgetProvider4x1 extends WidgetProvider {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.cappielloantonio.tempo.widget;
|
||||
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.text.TextUtils;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public final class WidgetUpdateManager {
|
||||
|
||||
public static void updateFromState(Context ctx,
|
||||
String title,
|
||||
String artist,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode,
|
||||
long positionMs,
|
||||
long durationMs) {
|
||||
if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing);
|
||||
if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
|
||||
if (TextUtils.isEmpty(album)) album = "";
|
||||
|
||||
final TimingInfo timing = createTimingInfo(positionMs, durationMs);
|
||||
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
|
||||
timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
|
||||
public static void pushNow(Context ctx) {
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = chooseBuild(ctx, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
|
||||
public static void updateFromState(Context ctx,
|
||||
String title,
|
||||
String artist,
|
||||
String album,
|
||||
String coverArtId,
|
||||
boolean playing,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode,
|
||||
long positionMs,
|
||||
long durationMs) {
|
||||
final Context appCtx = ctx.getApplicationContext();
|
||||
final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title;
|
||||
final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist;
|
||||
final String alb = !TextUtils.isEmpty(album) ? album : "";
|
||||
final boolean p = playing;
|
||||
final boolean sh = shuffleEnabled;
|
||||
final int rep = repeatMode;
|
||||
final TimingInfo timing = createTimingInfo(positionMs, durationMs);
|
||||
|
||||
if (!TextUtils.isEmpty(coverArtId)) {
|
||||
CustomGlideRequest.loadAlbumArtBitmap(
|
||||
appCtx,
|
||||
coverArtId,
|
||||
com.cappielloantonio.tempo.util.Preferences.getImageSize(),
|
||||
new CustomTarget<Bitmap>() {
|
||||
@Override
|
||||
public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) {
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadCleared(Drawable placeholder) {
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void refreshFromController(Context ctx) {
|
||||
final Context appCtx = ctx.getApplicationContext();
|
||||
SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
|
||||
ListenableFuture<MediaController> future = new MediaController.Builder(appCtx, token).buildAsync();
|
||||
future.addListener(() -> {
|
||||
try {
|
||||
if (!future.isDone()) return;
|
||||
MediaController c = future.get();
|
||||
androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
|
||||
String title = null, artist = null, album = null, coverId = null;
|
||||
if (mi != null && mi.mediaMetadata != null) {
|
||||
if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
|
||||
if (mi.mediaMetadata.artist != null)
|
||||
artist = mi.mediaMetadata.artist.toString();
|
||||
if (mi.mediaMetadata.albumTitle != null)
|
||||
album = mi.mediaMetadata.albumTitle.toString();
|
||||
if (mi.mediaMetadata.extras != null) {
|
||||
if (title == null) title = mi.mediaMetadata.extras.getString("title");
|
||||
if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
|
||||
if (album == null) album = mi.mediaMetadata.extras.getString("album");
|
||||
coverId = mi.mediaMetadata.extras.getString("coverArtId");
|
||||
}
|
||||
}
|
||||
long position = c.getCurrentPosition();
|
||||
long duration = c.getDuration();
|
||||
if (position == C.TIME_UNSET) position = 0;
|
||||
if (duration == C.TIME_UNSET) duration = 0;
|
||||
updateFromState(appCtx,
|
||||
title != null ? title : appCtx.getString(R.string.widget_not_playing),
|
||||
artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle),
|
||||
album,
|
||||
coverId,
|
||||
c.isPlaying(),
|
||||
c.getShuffleModeEnabled(),
|
||||
c.getRepeatMode(),
|
||||
position,
|
||||
duration);
|
||||
c.release();
|
||||
} catch (ExecutionException | InterruptedException ignored) {
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
private static TimingInfo createTimingInfo(long positionMs, long durationMs) {
|
||||
long safePosition = Math.max(0L, positionMs);
|
||||
long safeDuration = durationMs > 0 ? durationMs : 0L;
|
||||
if (safeDuration > 0 && safePosition > safeDuration) {
|
||||
safePosition = safeDuration;
|
||||
}
|
||||
|
||||
String elapsed = (safeDuration > 0 || safePosition > 0)
|
||||
? MusicUtil.getReadableDurationString(safePosition, true)
|
||||
: null;
|
||||
String total = safeDuration > 0
|
||||
? MusicUtil.getReadableDurationString(safeDuration, true)
|
||||
: null;
|
||||
|
||||
int progress = 0;
|
||||
if (safeDuration > 0) {
|
||||
long scaled = safePosition * WidgetViewsFactory.PROGRESS_MAX;
|
||||
long progressLong = scaled / safeDuration;
|
||||
if (progressLong < 0) {
|
||||
progress = 0;
|
||||
} else if (progressLong > WidgetViewsFactory.PROGRESS_MAX) {
|
||||
progress = WidgetViewsFactory.PROGRESS_MAX;
|
||||
} else {
|
||||
progress = (int) progressLong;
|
||||
}
|
||||
}
|
||||
|
||||
return new TimingInfo(elapsed, total, progress);
|
||||
}
|
||||
|
||||
public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) {
|
||||
LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
|
||||
switch (size) {
|
||||
case MEDIUM:
|
||||
return WidgetViewsFactory.buildMedium(ctx);
|
||||
case LARGE:
|
||||
return WidgetViewsFactory.buildLarge(ctx);
|
||||
case EXPANDED:
|
||||
return WidgetViewsFactory.buildExpanded(ctx);
|
||||
case COMPACT:
|
||||
default:
|
||||
return WidgetViewsFactory.buildCompact(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private static android.widget.RemoteViews choosePopulate(Context ctx,
|
||||
String title,
|
||||
String artist,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode,
|
||||
int appWidgetId) {
|
||||
LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
|
||||
switch (size) {
|
||||
case MEDIUM:
|
||||
return WidgetViewsFactory.populateMedium(ctx, title, artist, album, art, playing,
|
||||
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
|
||||
case LARGE:
|
||||
return WidgetViewsFactory.populateLarge(ctx, title, artist, album, art, playing,
|
||||
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
|
||||
case EXPANDED:
|
||||
return WidgetViewsFactory.populateExpanded(ctx, title, artist, album, art, playing,
|
||||
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
|
||||
case COMPACT:
|
||||
default:
|
||||
return WidgetViewsFactory.populateCompact(ctx, title, artist, album, art, playing,
|
||||
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
|
||||
}
|
||||
}
|
||||
|
||||
private static LayoutSize resolveLayoutSize(Context ctx, int appWidgetId) {
|
||||
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
|
||||
android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId);
|
||||
int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0;
|
||||
int expandedThreshold = ctx.getResources().getInteger(R.integer.widget_expanded_min_height_dp);
|
||||
int largeThreshold = ctx.getResources().getInteger(R.integer.widget_large_min_height_dp);
|
||||
int mediumThreshold = ctx.getResources().getInteger(R.integer.widget_medium_min_height_dp);
|
||||
if (minH >= expandedThreshold) return LayoutSize.EXPANDED;
|
||||
if (minH >= largeThreshold) return LayoutSize.LARGE;
|
||||
if (minH >= mediumThreshold) return LayoutSize.MEDIUM;
|
||||
return LayoutSize.COMPACT;
|
||||
}
|
||||
|
||||
private enum LayoutSize {
|
||||
COMPACT,
|
||||
MEDIUM,
|
||||
LARGE,
|
||||
EXPANDED
|
||||
}
|
||||
|
||||
private static final class TimingInfo {
|
||||
final String elapsedText;
|
||||
final String totalText;
|
||||
final int progress;
|
||||
|
||||
TimingInfo(String elapsedText, String totalText, int progress) {
|
||||
this.elapsedText = elapsedText;
|
||||
this.totalText = totalText;
|
||||
this.progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.cappielloantonio.tempo.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapShader;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Shader;
|
||||
import android.text.TextUtils;
|
||||
import android.util.TypedValue;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.Player;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
|
||||
public final class WidgetViewsFactory {
|
||||
|
||||
static final int PROGRESS_MAX = 1000;
|
||||
private static final float ALBUM_ART_CORNER_RADIUS_DP = 6f;
|
||||
|
||||
private WidgetViewsFactory() {
|
||||
}
|
||||
|
||||
public static RemoteViews buildCompact(Context ctx) {
|
||||
return build(ctx, R.layout.widget_layout_compact, false, false);
|
||||
}
|
||||
|
||||
public static RemoteViews buildMedium(Context ctx) {
|
||||
return build(ctx, R.layout.widget_layout_medium, false, false);
|
||||
}
|
||||
|
||||
public static RemoteViews buildLarge(Context ctx) {
|
||||
return build(ctx, R.layout.widget_layout_large_short, true, true);
|
||||
}
|
||||
|
||||
public static RemoteViews buildExpanded(Context ctx) {
|
||||
return build(ctx, R.layout.widget_layout_large, true, true);
|
||||
}
|
||||
|
||||
private static RemoteViews build(Context ctx,
|
||||
int layoutRes,
|
||||
boolean showAlbum,
|
||||
boolean showSecondaryControls) {
|
||||
RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
|
||||
rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing));
|
||||
rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle));
|
||||
rv.setTextViewText(R.id.album, "");
|
||||
rv.setViewVisibility(R.id.album, showAlbum ? View.INVISIBLE : View.GONE);
|
||||
rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder));
|
||||
rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder));
|
||||
rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false);
|
||||
rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play);
|
||||
rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
|
||||
applySecondaryControlsDefaults(ctx, rv, showSecondaryControls);
|
||||
return rv;
|
||||
}
|
||||
|
||||
private static void applySecondaryControlsDefaults(Context ctx,
|
||||
RemoteViews rv,
|
||||
boolean show) {
|
||||
int visibility = show ? View.VISIBLE : View.GONE;
|
||||
rv.setViewVisibility(R.id.controls_secondary, visibility);
|
||||
rv.setViewVisibility(R.id.btn_shuffle, visibility);
|
||||
rv.setViewVisibility(R.id.btn_repeat, visibility);
|
||||
if (show) {
|
||||
int defaultColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
|
||||
rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
|
||||
rv.setImageViewResource(R.id.btn_repeat, R.drawable.ic_repeat);
|
||||
rv.setInt(R.id.btn_shuffle, "setColorFilter", defaultColor);
|
||||
rv.setInt(R.id.btn_repeat, "setColorFilter", defaultColor);
|
||||
}
|
||||
}
|
||||
|
||||
public static RemoteViews populateCompact(Context ctx,
|
||||
String title,
|
||||
String subtitle,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
|
||||
progress, R.layout.widget_layout_compact, false, false, shuffleEnabled, repeatMode);
|
||||
}
|
||||
|
||||
public static RemoteViews populateMedium(Context ctx,
|
||||
String title,
|
||||
String subtitle,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
|
||||
progress, R.layout.widget_layout_medium, true, true, shuffleEnabled, repeatMode);
|
||||
}
|
||||
|
||||
public static RemoteViews populateLarge(Context ctx,
|
||||
String title,
|
||||
String subtitle,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
|
||||
progress, R.layout.widget_layout_large_short, true, true, shuffleEnabled, repeatMode);
|
||||
}
|
||||
|
||||
public static RemoteViews populateExpanded(Context ctx,
|
||||
String title,
|
||||
String subtitle,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
|
||||
progress, R.layout.widget_layout_large, true, true, shuffleEnabled, repeatMode);
|
||||
}
|
||||
|
||||
private static RemoteViews populateWithLayout(Context ctx,
|
||||
String title,
|
||||
String subtitle,
|
||||
String album,
|
||||
Bitmap art,
|
||||
boolean playing,
|
||||
String elapsedText,
|
||||
String totalText,
|
||||
int progress,
|
||||
int layoutRes,
|
||||
boolean showAlbum,
|
||||
boolean showSecondaryControls,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
|
||||
rv.setTextViewText(R.id.title, title);
|
||||
rv.setTextViewText(R.id.subtitle, subtitle);
|
||||
|
||||
if (showAlbum && !TextUtils.isEmpty(album)) {
|
||||
rv.setTextViewText(R.id.album, album);
|
||||
rv.setViewVisibility(R.id.album, View.VISIBLE);
|
||||
} else {
|
||||
rv.setTextViewText(R.id.album, "");
|
||||
rv.setViewVisibility(R.id.album, View.GONE);
|
||||
}
|
||||
|
||||
if (art != null) {
|
||||
Bitmap rounded = maybeRoundBitmap(ctx, art);
|
||||
rv.setImageViewBitmap(R.id.album_art, rounded != null ? rounded : art);
|
||||
} else {
|
||||
rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
|
||||
}
|
||||
|
||||
rv.setImageViewResource(R.id.btn_play_pause,
|
||||
playing ? R.drawable.ic_pause : R.drawable.ic_play);
|
||||
|
||||
String elapsed = !TextUtils.isEmpty(elapsedText)
|
||||
? elapsedText
|
||||
: ctx.getString(R.string.widget_time_elapsed_placeholder);
|
||||
String total = !TextUtils.isEmpty(totalText)
|
||||
? totalText
|
||||
: ctx.getString(R.string.widget_time_duration_placeholder);
|
||||
|
||||
int safeProgress = progress;
|
||||
if (safeProgress < 0) safeProgress = 0;
|
||||
if (safeProgress > PROGRESS_MAX) safeProgress = PROGRESS_MAX;
|
||||
|
||||
rv.setTextViewText(R.id.time_elapsed, elapsed);
|
||||
rv.setTextViewText(R.id.time_total, total);
|
||||
rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false);
|
||||
|
||||
applySecondaryControls(ctx, rv, showSecondaryControls, shuffleEnabled, repeatMode);
|
||||
|
||||
return rv;
|
||||
}
|
||||
|
||||
private static Bitmap maybeRoundBitmap(Context ctx, Bitmap source) {
|
||||
if (source == null || source.isRecycled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(output);
|
||||
|
||||
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
|
||||
|
||||
float radiusPx = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
ALBUM_ART_CORNER_RADIUS_DP,
|
||||
ctx.getResources().getDisplayMetrics());
|
||||
float maxRadius = Math.min(width, height) / 2f;
|
||||
float safeRadius = Math.min(radiusPx, maxRadius);
|
||||
|
||||
canvas.drawRoundRect(new RectF(0f, 0f, width, height), safeRadius, safeRadius, paint);
|
||||
return output;
|
||||
} catch (RuntimeException | OutOfMemoryError e) {
|
||||
android.util.Log.w("TempoWidget", "Failed to round album art", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySecondaryControls(Context ctx,
|
||||
RemoteViews rv,
|
||||
boolean show,
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode) {
|
||||
if (!show) {
|
||||
rv.setViewVisibility(R.id.controls_secondary, View.GONE);
|
||||
rv.setViewVisibility(R.id.btn_shuffle, View.GONE);
|
||||
rv.setViewVisibility(R.id.btn_repeat, View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
int inactiveColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
|
||||
int activeColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint_active);
|
||||
|
||||
rv.setViewVisibility(R.id.controls_secondary, View.VISIBLE);
|
||||
rv.setViewVisibility(R.id.btn_shuffle, View.VISIBLE);
|
||||
rv.setViewVisibility(R.id.btn_repeat, View.VISIBLE);
|
||||
rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
|
||||
rv.setImageViewResource(R.id.btn_repeat,
|
||||
repeatMode == Player.REPEAT_MODE_ONE ? R.drawable.ic_repeat_one : R.drawable.ic_repeat);
|
||||
rv.setInt(R.id.btn_shuffle, "setColorFilter", shuffleEnabled ? activeColor : inactiveColor);
|
||||
rv.setInt(R.id.btn_repeat, "setColorFilter",
|
||||
repeatMode == Player.REPEAT_MODE_OFF ? inactiveColor : activeColor);
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_eq.xml
Normal file
11
app/src/main/res/drawable/ic_eq.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:autoMirrored="true">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/titleTextColor"
|
||||
android:pathData="M160,800L160,480L320,480L320,800L160,800ZM400,800L400,160L560,160L560,800L400,800ZM640,800L640,360L800,360L800,800L640,800Z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_folder.xml
Normal file
9
app/src/main/res/drawable/ic_folder.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/titleTextColor"
|
||||
android:pathData="M10,4L12,6H20c1.1,0 2,0.9 2,2v10c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h6z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_refresh.xml
Normal file
9
app/src/main/res/drawable/ic_refresh.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/titleTextColor"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8h2c0,-3.31 2.69,-6 6,-6 1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35zM19,12c0,3.31 -2.69,6 -6,6 -1.66,0 -3.14,-0.69 -4.22,-1.78L11,13H4v7l2.35,-2.35C7.8,19.1 9.79,20 12,20c4.42,0 8,-3.58 8,-8h-2z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_repeat_one.xml
Normal file
12
app/src/main/res/drawable/ic_repeat_one.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/titleTextColor"
|
||||
android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z" />
|
||||
<path
|
||||
android:fillColor="@color/titleTextColor"
|
||||
android:pathData="M12,9h-2v2h1v6h2V9h-1z" />
|
||||
</vector>
|
||||
93
app/src/main/res/drawable/ui_eq_not_supported.xml
Normal file
93
app/src/main/res/drawable/ui_eq_not_supported.xml
Normal file
@@ -0,0 +1,93 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="757.96dp"
|
||||
android:height="743.73dp"
|
||||
android:viewportWidth="757.96"
|
||||
android:viewportHeight="743.73">
|
||||
<path
|
||||
android:pathData="M91.45,0a32.04,32.04 0,0 0,-32 32L59.45,710.43a32.04,32.04 0,0 0,32 32h297a32.04,32.04 0,0 0,32 -32L420.45,32a32.04,32.04 0,0 0,-32 -32Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M400.66,156.98v-54.44a125.25,125.25 0,0 1,-80.86 -60.19h0a23.79,23.79 0,0 1,-14.22 4.68L262.35,47.03A178.55,178.55 0,0 0,400.66 156.98Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M400.66,99.42v-52.3a29.12,29.12 0,0 0,-29.13 -29.13h-41.97v5.05a23.92,23.92 0,0 1,-7.4 17.33,122.3 122.3,0 0,0 78.5,59.05Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M198.77,47.03L171.74,47.03a23.99,23.99 0,0 1,-23.98 -23.99v-5.05L108.38,17.99a29.13,29.13 0,0 0,-29.13 29.13v648.2a29.08,29.08 0,0 0,29.13 29.11h263.15a28.36,28.36 0,0 0,3.59 -0.22,29.15 29.15,0 0,0 25.54,-28.89L400.66,218.15C304.95,207.07 225.2,138.77 198.77,47.03Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M259.07,47.03h-57.14c26.3,90.04 104.68,157.03 198.73,168.07v-55.02A181.67,181.67 0,0 1,259.07 47.03Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M380.61,532.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,460.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 532.78ZM110.61,457.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,460.81a3,3 0,0 0,-3 -3Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M145.61,494.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M194.11,480.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M194.11,501.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M380.61,644.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,572.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 644.78ZM110.61,569.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,572.81a3,3 0,0 0,-3 -3Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M145.61,606.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M194.11,592.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M194.11,613.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
|
||||
android:fillColor="#e6e6e6"/>
|
||||
<path
|
||||
android:pathData="M239.93,394a94.96,94.96 0,0 1,-95 -95c0,-0.2 0,-0.41 0.01,-0.61 0.29,-52.03 42.9,-94.39 94.99,-94.39a95,95 0,1 1,0 190ZM239.93,206a93.2,93.2 0,0 0,-92.99 92.46c-0.01,0.21 -0.01,0.38 -0.01,0.54a93.01,93.01 0,1 0,93 -93Z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M282.95,296.81l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46Z"
|
||||
android:fillColor="#6c63ff"/>
|
||||
<path
|
||||
android:pathData="M757.57,743.73H0v-2.18H757.96Z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M590.68,338.14m-27.94,0a27.94,27.94 0,1 1,55.87 0a27.94,27.94 0,1 1,-55.87 0"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M588.87,494.75a12.51,12.51 0,0 1,9.47 -16.1,11.89 11.89,0 0,1 1.66,-0.2l29.43,-47.23L602.55,405.66A10.73,10.73 0,1 1,617.47 390.25l37.11,36.6 0.08,0.09a9.72,9.72 0,0 1,-0.68 11.58L612.75,487.28a11.73,11.73 0,0 1,0.31 1.19,12.51 12.51,0 0,1 -11.23,14.92q-0.53,0.05 -1.06,0.05A12.55,12.55 0,0 1,588.87 494.75Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M544.67,726.93L530.72,726.93l-6.63,-53.79 20.58,0Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M548.79,741.02l-46.1,0L502.69,739.88a18.07,18.07 0,0 1,18.07 -18.07h28.03Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M683.27,707.66l-11.98,7.14 -33.22,-42.82 17.68,-10.53Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M654.41,741.23l-0.58,-0.98a18.07,18.07 0,0 1,6.28 -24.77l24.08,-14.34 9.83,16.5Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M522.33,703.25c-9.34,-109.99 -14.9,-212.18 19.25,-253.86l0.26,-0.32 57.47,22.99 0.09,0.2c0.19,0.42 19.31,42.46 14.85,70.74l14.18,65.21 46.22,77.39a5.12,5.12 0,0 1,-2.33 7.31l-20.09,8.84a5.14,5.14 0,0 1,-6.42 -2.01L595.53,617.75l-28.4,-62.88a1.71,1.71 0,0 0,-3.25 0.52L548.14,703.36a5.11,5.11 0,0 1,-5.09 4.58L527.43,707.94A5.15,5.15 0,0 1,522.33 703.25Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M541.77,450.26l-0.27,-0.13 -0.04,-0.3c-2.15,-15.02 0.39,-31.72 7.55,-49.62a39.4,39.4 0,0 1,45.73 -23.59h0a39.35,39.35 0,0 1,25.09 19.3,38.92 38.92,0 0,1 2.7,31.19c-9.02,26.39 -20.73,51.08 -20.85,51.32l-0.25,0.51Z"
|
||||
android:fillColor="#6c63ff"/>
|
||||
<path
|
||||
android:pathData="M500.42,512.57a12.78,12.78 0,0 1,9.16 -13.94l53.74,-103.17a10.3,10.3 0,1 1,17.52 10.82L525.84,508.73a12.42,12.42 0,0 1,0.2 1.89,12.86 12.86,0 0,1 -13.03,13.21h0a12.87,12.87 0,0 1,-9.87 -4.83,12.71 12.71,0 0,1 -2.71,-6.43Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M556.81,322.35h44.36L601.16,303.02c-9.74,-3.87 -19.26,-7.16 -25.02,0a19.34,19.34 0,0 0,-19.34 19.34Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M603.62,299.61c26.52,0 33.94,33.24 33.94,51.99 0,10.46 -4.73,14.2 -12.16,15.46l-2.63,-14 -6.15,14.6c-2.09,0.01 -4.28,-0.03 -6.55,-0.07l-2.08,-4.29 -4.65,4.22c-18.62,0.03 -33.66,2.74 -33.66,-15.92C569.68,332.85 576.19,299.61 603.62,299.61Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M595.72,327L595.72,301.13a2.33,2.33 0,0 0,-2.33 -2.33h-4.67a2.33,2.33 0,0 0,-2.33 2.33L586.39,325.44a14.74,14.74 0,1 0,9.33 1.56Z"
|
||||
android:fillColor="#6c63ff"/>
|
||||
<path
|
||||
android:pathData="M589.5,340.01m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/widget_bg.xml
Normal file
6
app/src/main/res/drawable/widget_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="10dp" />
|
||||
<solid android:color="@color/widget_bg" />
|
||||
</shape>
|
||||
@@ -382,11 +382,23 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_queue" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_open_equalizer_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_eq" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -19,7 +19,8 @@
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/playlist_dialog_recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="8dp"
|
||||
android:clipToPadding="false" />
|
||||
</LinearLayout>
|
||||
14
app/src/main/res/layout/dialog_starred_artist_sync.xml
Normal file
14
app/src/main/res/layout/dialog_starred_artist_sync.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/starred_artist_sync_dialog_summary" />
|
||||
</LinearLayout>
|
||||
@@ -49,8 +49,9 @@
|
||||
<TextView
|
||||
android:id="@+id/subtitle_empty_description_label"
|
||||
style="@style/LabelSmall"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingStart="56dp"
|
||||
android:paddingEnd="56dp"
|
||||
android:text="@string/download_info_empty_subtitle" />
|
||||
@@ -79,7 +80,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/download_title_section"
|
||||
app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view"
|
||||
app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
@@ -93,6 +94,19 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/downloaded_refresh_image_view"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="@drawable/ic_refresh"
|
||||
android:contentDescription="@string/download_refresh_button_content_description"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
|
||||
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
|
||||
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
|
||||
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/downloaded_go_back_image_view"
|
||||
android:layout_width="24dp"
|
||||
@@ -102,6 +116,7 @@
|
||||
android:background="@drawable/ic_arrow_back"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
|
||||
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
|
||||
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
|
||||
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
|
||||
|
||||
<ImageView
|
||||
|
||||
105
app/src/main/res/layout/fragment_equalizer.xml
Normal file
105
app/src/main/res/layout/fragment_equalizer.xml
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/eq_frame_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/eq_scroll_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/eq_root_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/equalizer_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/equalizer_fragment_title"
|
||||
style="@style/HeadlineSmall"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:paddingBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/equalizer_switch_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/equalizer_switch_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
style="@style/LabelMedium"
|
||||
android:text="@string/equalizer_enable" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/equalizer_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/eq_bands_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/equalizer_reset_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/equalizer_reset"
|
||||
android:layout_gravity="center_horizontal"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="24dp"/>
|
||||
|
||||
<Space
|
||||
android:id="@+id/equalizer_bottom_space"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="128dp"
|
||||
android:layout_marginTop="0dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/equalizer_not_supported_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/equalizer_not_supported_image"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:adjustViewBounds="true"
|
||||
android:maxWidth="240dp"
|
||||
android:maxHeight="240dp"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ui_eq_not_supported" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/equalizer_not_supported_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/equalizer_not_supported"
|
||||
android:gravity="center"
|
||||
style="@style/BodyMedium"
|
||||
android:layout_marginTop="16dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -198,6 +198,98 @@
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Download/Sync starred artists -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/home_sync_starred_artists_card"
|
||||
style="?attr/materialCardViewOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<!-- Title, secondary and supporting text -->
|
||||
<TextView
|
||||
android:id="@+id/home_sync_starred_artists_title"
|
||||
style="@style/TitleLarge"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/home_sync_starred_artists_title"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textFontWeight="600"
|
||||
app:layout_constraintEnd_toStartOf="@id/vertical_guideline_artists"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_sync_starred_artists_subtitle"
|
||||
style="@style/TitleMedium"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/home_sync_starred_artists_subtitle"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_sync_starred_artists_to_sync"
|
||||
style="@style/TitleSmall"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="16dp"
|
||||
android:text="@string/home_sync_starred_artists_subtitle"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_subtitle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_to_sync">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/home_sync_starred_artists_cancel"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/home_sync_starred_cancel" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/home_sync_starred_artists_download"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/home_sync_starred_download" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/vertical_guideline_artists"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent="0.90" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Discover music -->
|
||||
<LinearLayout
|
||||
android:id="@+id/home_discover_sector"
|
||||
|
||||
@@ -381,11 +381,23 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_queue" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_open_equalizer_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_eq" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -51,7 +51,25 @@
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<Button
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/download_lyrics_button"
|
||||
style="@style/Widget.Material3.Button.TonalButton.Icon"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="16dp"
|
||||
android:alpha="0.7"
|
||||
android:contentDescription="@string/player_lyrics_download_content_description"
|
||||
android:insetLeft="0dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetRight="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:visibility="gone"
|
||||
app:cornerRadius="64dp"
|
||||
app:icon="@drawable/ic_download"
|
||||
app:layout_constraintBottom_toTopOf="@+id/sync_lyrics_tap_button"
|
||||
app:layout_constraintEnd_toEndOf="@+id/now_playing_song_lyrics_sroll_view" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sync_lyrics_tap_button"
|
||||
style="@style/Widget.Material3.Button.TonalButton.Icon"
|
||||
android:layout_width="48dp"
|
||||
|
||||
@@ -55,6 +55,27 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
|
||||
|
||||
<View
|
||||
android:id="@+id/cover_art_overlay"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="#80000000"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/song_cover_image_view"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/play_pause_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="28dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_number_text_view"
|
||||
style="@style/LabelLarge"
|
||||
|
||||
@@ -20,6 +20,27 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/cover_art_overlay"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:background="#80000000"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/play_pause_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_margin="14dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/queue_song_title_text_view"
|
||||
style="@style/LabelMedium"
|
||||
|
||||
175
app/src/main/res/layout/widget_layout_compact.xml
Normal file
175
app/src/main/res/layout/widget_layout_compact.xml
Normal file
@@ -0,0 +1,175 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/widget_content_desc_album_art"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/texts"
|
||||
android:orientation="vertical"
|
||||
android:layout_toRightOf="@id/album_art"
|
||||
android:layout_toEndOf="@id/album_art"
|
||||
android:layout_toLeftOf="@id/controls"
|
||||
android:layout_toStartOf="@id/controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/widget_title"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/album"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="2dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:indeterminate="false"
|
||||
android:max="1000"
|
||||
android:progress="0"
|
||||
android:progressBackgroundTint="@color/widget_subtitle"
|
||||
android:progressTint="@color/widget_icon_tint"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/timing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_elapsed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/widget_time_elapsed_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="10sp"
|
||||
android:includeFontPadding="false"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_total"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="@string/widget_time_duration_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="10sp"
|
||||
android:includeFontPadding="false"/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls_secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="4dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_shuffle"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_shuffle"
|
||||
android:src="@drawable/ic_shuffle"
|
||||
android:tint="@color/widget_icon_tint"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_repeat"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_repeat"
|
||||
android:src="@drawable/ic_repeat"
|
||||
android:tint="@color/widget_icon_tint"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_prev"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_skip_previous"
|
||||
android:contentDescription="@string/widget_content_desc_prev"
|
||||
android:tint="@color/widget_icon_tint"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_play_pause"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_play"
|
||||
android:contentDescription="@string/widget_content_desc_play_pause"
|
||||
android:tint="@color/widget_icon_tint"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_skip_next"
|
||||
android:contentDescription="@string/widget_content_desc_next"
|
||||
android:tint="@color/widget_icon_tint"/>
|
||||
</LinearLayout>
|
||||
</RelativeLayout>
|
||||
189
app/src/main/res/layout/widget_layout_large.xml
Normal file
189
app/src/main/res/layout/widget_layout_large.xml
Normal file
@@ -0,0 +1,189 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="200dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:baselineAligned="false">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="150dp"
|
||||
android:layout_height="150dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/widget_content_desc_album_art" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/text_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textStyle="bold"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/widget_title"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/album"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:visibility="invisible" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="false"
|
||||
android:max="1000"
|
||||
android:progress="0"
|
||||
android:progressBackgroundTint="@color/widget_subtitle"
|
||||
android:progressTint="@color/widget_icon_tint" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/timing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_elapsed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/widget_time_elapsed_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_total"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="@string/widget_time_duration_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_prev"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_prev"
|
||||
android:src="@drawable/ic_skip_previous"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_play_pause"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_play_pause"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_next"
|
||||
android:src="@drawable/ic_skip_next"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls_secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_shuffle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_shuffle"
|
||||
android:src="@drawable/ic_shuffle"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_repeat"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_repeat"
|
||||
android:src="@drawable/ic_repeat"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
198
app/src/main/res/layout/widget_layout_large_short.xml
Normal file
198
app/src/main/res/layout/widget_layout_large_short.xml
Normal file
@@ -0,0 +1,198 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="172dp"
|
||||
android:padding="16dp"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:baselineAligned="false"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/album_art_container"
|
||||
android:layout_width="90dp"
|
||||
android:layout_height="90dp"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/widget_content_desc_album_art" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/text_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textStyle="bold"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/widget_title"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/album"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:visibility="invisible" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:indeterminate="false"
|
||||
android:max="1000"
|
||||
android:progress="0"
|
||||
android:progressBackgroundTint="@color/widget_subtitle"
|
||||
android:progressTint="@color/widget_icon_tint" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/timing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_elapsed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/widget_time_elapsed_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_total"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="@string/widget_time_duration_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls_secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_shuffle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_shuffle"
|
||||
android:src="@drawable/ic_shuffle"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_weight="3"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_prev"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_prev"
|
||||
android:src="@drawable/ic_skip_previous"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_play_pause"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_play_pause"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_next"
|
||||
android:src="@drawable/ic_skip_next"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_repeat"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_repeat"
|
||||
android:src="@drawable/ic_repeat"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
216
app/src/main/res/layout/widget_layout_medium.xml
Normal file
216
app/src/main/res/layout/widget_layout_medium.xml
Normal file
@@ -0,0 +1,216 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="120dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:baselineAligned="false"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/album_art_container"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/widget_content_desc_album_art" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:weightSum="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/text_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textStyle="bold"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/widget_title"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/subtitle_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="1dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:baselineAligned="false">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/album"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:gravity="end"
|
||||
android:textAlignment="viewEnd"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:includeFontPadding="false"
|
||||
android:freezesText="true"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="3dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:indeterminate="false"
|
||||
android:max="1000"
|
||||
android:progress="0"
|
||||
android:progressBackgroundTint="@color/widget_subtitle"
|
||||
android:progressTint="@color/widget_icon_tint" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/timing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_elapsed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/widget_time_elapsed_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_total"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="@string/widget_time_duration_placeholder"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls_secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_shuffle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_shuffle"
|
||||
android:src="@drawable/ic_shuffle"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="3"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_prev"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_prev"
|
||||
android:src="@drawable/ic_skip_previous"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_play_pause"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_play_pause"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_next"
|
||||
android:src="@drawable/ic_skip_next"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_repeat"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/widget_content_desc_repeat"
|
||||
android:src="@drawable/ic_repeat"
|
||||
android:tint="@color/widget_icon_tint" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
82
app/src/main/res/layout/widget_preview_compact.xml
Normal file
82
app/src/main/res/layout/widget_preview_compact.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="0dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/ic_splash_logo"
|
||||
android:contentDescription="@string/widget_content_desc_album_art"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/texts"
|
||||
android:orientation="vertical"
|
||||
android:layout_toEndOf="@id/album_art"
|
||||
android:layout_toStartOf="@id/controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/widget_title"
|
||||
android:text="@string/widget_not_playing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_subtitle"
|
||||
android:text="@string/widget_placeholder_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/controls"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageButton android:id="@+id/btn_prev"
|
||||
android:layout_width="48dp" android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_skip_previous"
|
||||
android:tint="@color/widget_icon_tint"
|
||||
android:contentDescription="@string/widget_content_desc_prev"/>
|
||||
|
||||
<ImageButton android:id="@+id/btn_play_pause"
|
||||
android:layout_width="48dp" android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/widget_icon_tint"
|
||||
android:contentDescription="@string/widget_content_desc_play_pause"/>
|
||||
|
||||
<ImageButton android:id="@+id/btn_next"
|
||||
android:layout_width="48dp" android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@drawable/ic_skip_next"
|
||||
android:tint="@color/widget_icon_tint"
|
||||
android:contentDescription="@string/widget_content_desc_next"/>
|
||||
</LinearLayout>
|
||||
</RelativeLayout>
|
||||
@@ -16,4 +16,7 @@
|
||||
<item
|
||||
android:id="@+id/menu_download_group_by_year"
|
||||
android:title="@string/menu_group_by_year" />
|
||||
<item
|
||||
android:id="@+id/menu_download_set_directory"
|
||||
android:title="@string/download_directory_set" />
|
||||
</menu>
|
||||
@@ -151,6 +151,9 @@
|
||||
app:destination="@id/loginFragment"
|
||||
app:popUpTo="@id/homeFragment"
|
||||
app:popUpToInclusive="true" />
|
||||
<action
|
||||
android:id="@+id/action_settingsFragment_to_equalizerFragment"
|
||||
app:destination="@id/equalizerFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/searchFragment"
|
||||
@@ -300,6 +303,20 @@
|
||||
android:id="@+id/action_indexFragment_to_directoryFragment"
|
||||
app:destination="@id/directoryFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/playerControllerFragment"
|
||||
android:name="com.cappielloantonio.tempo.ui.fragment.PlayerControllerFragment"
|
||||
android:label="PlayerControllerFragment"
|
||||
tools:layout="@layout/inner_fragment_player_controller">
|
||||
<action
|
||||
android:id="@+id/action_playerControllerFragment_to_equalizerFragment"
|
||||
app:destination="@id/equalizerFragment"/>
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/equalizerFragment"
|
||||
android:name="com.cappielloantonio.tempo.ui.fragment.EqualizerFragment"
|
||||
android:label="EqualizerFragment"
|
||||
tools:layout="@layout/fragment_equalizer" />
|
||||
<dialog
|
||||
android:id="@+id/songBottomSheetDialog"
|
||||
android:name="com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog"
|
||||
|
||||
@@ -282,8 +282,8 @@
|
||||
<string name="settings_delete_download_storage_summary">Wenn Du weitermachst werden alle gespeicherten Inhalte unwiderruflich gelöscht.</string>
|
||||
<string name="settings_delete_download_storage_title">Gespeicherte Inhalte löschen</string>
|
||||
<string name="settings_download_storage_title">Download storage</string>
|
||||
<string name="settings_equalizer_summary">Audio Einstellungen anpassen</string>
|
||||
<string name="settings_equalizer_title">Equalizer</string>
|
||||
<string name="settings_system_equalizer_summary">Audio Einstellungen anpassen</string>
|
||||
<string name="settings_system_equalizer_title">System-Equalizer</string>
|
||||
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
|
||||
<string name="settings_github_summary">Verfolge die Entwicklung</string>
|
||||
<string name="settings_github_title">Github</string>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="activity_battery_optimizations_summary">Por favor, desactive las optimizaciones de batería para continuar la reproducción multimedia mientras la pantalla está apagada.</string>
|
||||
<string name="activity_battery_optimizations_title">Optimizaciones de batería</string>
|
||||
<string name="activity_info_offline_mode">Modo sin conexión</string>
|
||||
<string name="album_bottom_sheet_add_to_playlist">Añadir a la lista de reproducción</string>
|
||||
<string name="album_bottom_sheet_add_to_queue">Añadir a la cola</string>
|
||||
<string name="album_bottom_sheet_download_all">Descargar todo</string>
|
||||
<string name="album_bottom_sheet_go_to_artist">Ir al artista</string>
|
||||
@@ -68,7 +69,8 @@
|
||||
<string name="download_directory_dialog_positive_button">Descargar</string>
|
||||
<string name="download_directory_dialog_summary">Se descargarán todas las pistas de esta carpeta. Las pistas en las subcarpetas no se descargarán.</string>
|
||||
<string name="download_directory_dialog_title">Descargar las pistas</string>
|
||||
<string name="download_info_empty_subtitle">Una vez que descargues un tema, lo encontrarás aquí</string>
|
||||
<string name="download_directory_set">Indicar ubicación de descarga</string>
|
||||
<string name="download_info_empty_subtitle">Una vez que descargues una pista, la encontrarás aquí</string>
|
||||
<string name="download_info_empty_title">No hay descargas</string>
|
||||
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s elementos</string>
|
||||
<string name="download_item_single_subtitle_formatter">%1$s elementos</string>
|
||||
@@ -78,7 +80,10 @@
|
||||
<string name="download_storage_dialog_title">Selecciona una opción de almacenamiento</string>
|
||||
<string name="download_storage_external_dialog_positive_button">Externo</string>
|
||||
<string name="download_storage_internal_dialog_negative_button">Interno</string>
|
||||
<string name="download_storage_directory_dialog_neutral_button">Directorio</string>
|
||||
<string name="download_title_section">Descargas</string>
|
||||
<string name="download_refresh_no_changes">No se han encontrado descargas que falten</string>
|
||||
<string name="download_refresh_button_content_description">Actualizar descargas</string>
|
||||
<string name="downloaded_bottom_sheet_add_to_queue">Añadir a la cola</string>
|
||||
<string name="downloaded_bottom_sheet_play_next">Reproducir siguiente</string>
|
||||
<string name="downloaded_bottom_sheet_remove">Eliminar</string>
|
||||
@@ -87,8 +92,11 @@
|
||||
<string name="error_required">Obligatorio</string>
|
||||
<string name="error_server_prefix">Se necesita un prefijo http o https</string>
|
||||
<string name="exo_download_notification_channel_name">Descargas</string>
|
||||
<string name="exo_controls_heart_on_description">Añadir a favoritos</string>
|
||||
<string name="cast_expanded_controller_loading">Cargando…</string>
|
||||
<string name="filter_info_selection">Selecciona dos o más filtros</string>
|
||||
<string name="filter_title">Filtrar</string>
|
||||
<string name="filter_artist">Filtrar artistas</string>
|
||||
<string name="filter_title_expanded">Filtrar géneros</string>
|
||||
<string name="generic_list_page_count">(%1$d)</string>
|
||||
<string name="generic_list_page_count_unknown">(+%1$d)</string>
|
||||
@@ -115,6 +123,8 @@
|
||||
<string name="home_sync_starred_download">Descargar</string>
|
||||
<string name="home_sync_starred_subtitle">Descargar estas pistas usará una gran cantidad de datos</string>
|
||||
<string name="home_sync_starred_title">Parece que hay algunas pistas destacadas para sincronizar</string>
|
||||
<string name="home_sync_starred_albums_subtitle">Los álbumes marcados como favoritos estarán disponibles en el modo sin conexión.</string>
|
||||
<string name="home_sync_starred_artists_subtitle">Has destacado artistas con música que no has descargado</string>
|
||||
<string name="home_title_best_of">Lo mejor de</string>
|
||||
<string name="home_title_discovery">Descubrir</string>
|
||||
<string name="home_title_discovery_shuffle_all_button">Todo en aleatorio</string>
|
||||
@@ -159,12 +169,16 @@
|
||||
<string name="login_title_expanded">Servidores de Subsonic</string>
|
||||
<string name="media_route_menu_title">Emitir</string>
|
||||
<string name="menu_add_button">Añadir</string>
|
||||
<string name="menu_add_to_playlist_button">Añadir a la lista de reproducción</string>
|
||||
<string name="menu_download_all_button">Descargar todo</string>
|
||||
<string name="menu_rate_album">Valorar álbum</string>
|
||||
<string name="menu_download_label">Descargas</string>
|
||||
<string name="menu_filter_all">Todo</string>
|
||||
<string name="menu_filter_download">Descargado</string>
|
||||
<string name="menu_group_by_album">Álbum</string>
|
||||
<string name="menu_group_by_artist">Artista</string>
|
||||
<string name="settings_scan_result">Escaneo: hay %1$d pistas</string>
|
||||
<string name="settings_support_title">Soporte al usuario</string>
|
||||
<string name="settings_image_size">Resolución de la imagen</string>
|
||||
<string name="settings_language">Idioma</string>
|
||||
<string name="settings_system_language">Idioma del sistema</string>
|
||||
@@ -195,6 +209,8 @@
|
||||
<string name="menu_sort_year">Año</string>
|
||||
<string name="player_playback_speed">%1$.2fx</string>
|
||||
<string name="player_queue_clean_all_button">Limpiar la cola de reproducción</string>
|
||||
<string name="player_queue_save_queue_success">Cola de reproducción guardada</string>
|
||||
<string name="player_lyrics_download_failure">La letra no se puede descargar</string>
|
||||
<string name="player_server_priority">Prioridad del servidor</string>
|
||||
<string name="player_unknown_format">Formato desconocido</string>
|
||||
<string name="player_transcoding">Transcodificando</string>
|
||||
@@ -205,6 +221,8 @@
|
||||
<string name="playlist_chooser_dialog_negative_button">Cancelar</string>
|
||||
<string name="playlist_chooser_dialog_neutral_button">Crear</string>
|
||||
<string name="playlist_chooser_dialog_title">Añadir a una lista de reproducción</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_failure">Error al añadir a la lista</string>
|
||||
<string name="playlist_chooser_dialog_toast_all_skipped">Todas las pistas se han descartado porque están repetidas</string>
|
||||
<string name="playlist_counted_tracks">%1$d pistas • %2$s</string>
|
||||
<string name="playlist_duration">Duración • %1$s</string>
|
||||
<string name="playlist_editor_dialog_action_delete_toast">Pulsación larga para eliminar</string>
|
||||
@@ -247,11 +265,12 @@
|
||||
<string name="rating_dialog_negative_button">Cancelar</string>
|
||||
<string name="rating_dialog_positive_button">Guardar</string>
|
||||
<string name="rating_dialog_title">Valorar</string>
|
||||
<string name="search_hint">Buscar tema, artistas o álbumes</string>
|
||||
<string name="search_hint">Buscar pista, artistas o álbumes</string>
|
||||
<string name="search_info_minimum_characters">Introduzca al menos tres caracteres</string>
|
||||
<string name="search_title_album">Álbumes</string>
|
||||
<string name="settings_equalizer_summary">Ajustes de audio</string>
|
||||
<string name="settings_equalizer_title">Ecualizador</string>
|
||||
<string name="settings_set_download_folder">Establecer la carpeta de descargas</string>
|
||||
<string name="settings_system_equalizer_summary">Ajustes de audio</string>
|
||||
<string name="settings_system_equalizer_title">Ecualizador del sistema</string>
|
||||
<string name="search_title_artist">Artistas</string>
|
||||
<string name="search_title_song">Pistas</string>
|
||||
<string name="server_signup_dialog_action_low_security">Baja seguridad</string>
|
||||
@@ -273,6 +292,7 @@
|
||||
<string name="settings_about_summary">Tempo es un cliente de música Subsonic ligero y de código abierto, diseñado nativamente para Android.</string>
|
||||
<string name="settings_about_title">Acerca de</string>
|
||||
<string name="settings_always_on_display">Pantalla siempre activa</string>
|
||||
<string name="settings_allow_playlist_duplicates_summary">Si está habilitada, no se comprobará si hay pistas repetidas cuando se añadan a la lista.</string>
|
||||
<string name="settings_audio_transcode_download_format">Formato de transcodificación</string>
|
||||
<string name="settings_audio_transcode_download_priority_summary">Si está habilitada, Tempo no descargará la pista con las opciones de transcodificación que aparecen a continuación.</string>
|
||||
<string name="settings_audio_transcode_download_priority_title">Dar prioridad a las opciones del servidor usadas para el streaming en las descargas</string>
|
||||
@@ -288,6 +308,8 @@
|
||||
<string name="settings_audio_transcode_priority_toast">Prioridad a la hora de transcodificar una pista</string>
|
||||
<string name="settings_buffering_strategy">Estrategia de buffer</string>
|
||||
<string name="settings_buffering_strategy_summary">Para que los cambios surtan efecto, debes reinciar la app.</string>
|
||||
<string name="settings_choose_download_folder">Elige una carpeta para descargar los archivos de música</string>
|
||||
<string name="settings_clear_download_folder">Limpiar la carpeta de descargas</string>
|
||||
<string name="settings_continuous_play_summary">Permite que la música siga reproduciéndose una vez que la lista de reproducción ha terminado, reproduciendo pistas similares</string>
|
||||
<string name="settings_continuous_play_title">Reproducción continua</string>
|
||||
<string name="settings_covers_cache">Tamaño de la caché de portadas de álbumes</string>
|
||||
@@ -306,14 +328,18 @@
|
||||
<string name="settings_podcast_summary">Si está habilitada, se mostrará la sección de pódcasts. Reinicia la aplicación para que los cambios surtan efecto.</string>
|
||||
<string name="settings_audio_quality">Mostrar calidad de audio</string>
|
||||
<string name="settings_audio_quality_summary">La tasa de bits y el formato de audio se mostrarán para cada pista de audio.</string>
|
||||
<string name="settings_song_rating_summary">Si está habilitada, muestra la valoración de la pista como barra de 5 estrellas en la página del control de reproducción.\n\n*Requiere reiniciar la aplicación</string>
|
||||
<string name="settings_item_rating">Mostrar valoración de los elementos</string>
|
||||
<string name="settings_queue_syncing_title">Sincronizar cola de reproducción para este usuario</string>
|
||||
<string name="settings_show_mini_shuffle_button_summary">Si está habilitada, muestra el botón de reproducción aleatoria y oculta el botón de «Favoritos» en el minirreproductor</string>
|
||||
<string name="settings_radio">Mostrar emisoras de radio</string>
|
||||
<string name="settings_auto_download_lyrics_summary">Descargar las letras automáticamente cuando estén disponibles para que se puedan mostrar cuando no hay conexión.</string>
|
||||
<string name="settings_replay_gain">Configurar el modo de ganancia de reproducción</string>
|
||||
<string name="settings_rounded_corner">Esquinas redondeadas</string>
|
||||
<string name="settings_rounded_corner_size">Tamaño de las esquinas</string>
|
||||
<string name="settings_rounded_corner_summary">Si está habilitada, establece un ángulo de curvatura para todas las portadas de álbumes. Los cambios se aplicarán después de reiniciar la app.</string>
|
||||
<string name="settings_scan_title">Escanear biblioteca</string>
|
||||
<string name="streaming_cache_storage_dialog_summary">Cambiar la ubicación de los archivos en caché a otro almacenamiento puede causar el borrado de todos los archivos en caché en el anterior almacenamiento.</string>
|
||||
<string name="streaming_cache_storage_dialog_title">Seleccióna un tipo de almacenamiento</string>
|
||||
<string name="streaming_cache_storage_external_dialog_positive_button">Externo</string>
|
||||
<string name="streaming_cache_storage_internal_dialog_negative_button">Interno</string>
|
||||
@@ -359,6 +385,7 @@
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_title_data">Datos</string>
|
||||
<string name="settings_title_general">General</string>
|
||||
<string name="settings_title_playlist">Lista de reproducción</string>
|
||||
<string name="settings_title_rating">Valoraciones</string>
|
||||
<string name="settings_title_replay_gain">Ganancia de reproducción</string>
|
||||
<string name="settings_title_scrobble">Rastreo de música (scrobble)</string>
|
||||
@@ -410,13 +437,53 @@
|
||||
<string name="settings_summary_transcoding">La prioridad que se aplica al modo de transcodificación. Si se selecciona \"Reproducción directa\", la tasa de bits del archivo no cambiará.</string>
|
||||
<string name="starred_sync_dialog_summary">Descargar las pistas destacadas podría consumir una gran cantidad de datos</string>
|
||||
<string name="starred_sync_dialog_title">Sincronizar las pistas destacadas</string>
|
||||
<string name="starred_album_sync_dialog_title">Sincronizar álbumes favoritos</string>
|
||||
<string name="streaming_cache_storage_dialog_sub_summary">Para que los cambios tengan efecto, reinicia la app.</string>
|
||||
<string name="settings_summary_transcoding_download">Descarga los archivos multimedia transcodificados. Si esta opción está habilitada, no se usará el endpoint de descarga, sino las siguientes opciones.\n\nSi el formato de transcodificación para las descargas se establece en \"Descarga directa\", no se modificará la tasa de bits del archivo.</string>
|
||||
<string name="settings_summary_transcoding_estimate_content_length">Cuando el archivo se transcodifica en tiempo real, el cliente normalmente no muestra la duración de la pista. Es posible solicitar a los servidores que soporten esta característica, que calculen la duración de la pista que se está reproduciendo, pero los tiempos de respuesta podrían aumentar.</string>
|
||||
<string name="settings_sync_starred_albums_for_offline_use_title">Sincronizar álbumes favoritos para uso sin conexión</string>
|
||||
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si está habilitada, las pistas destacadas se descargarán para uso sin conexión.</string>
|
||||
<string name="track_info_summary_downloaded_file">El archivo se ha descargado usando las APIs de Subsonic. El códec y la tasa de bits del archivo se mantienen sin cambios respecto al archivo de origen.</string>
|
||||
<string name="track_info_summary_full_transcode">La aplicación pedirá al servidor transcodificar el archivo y modificar su tasa de bits. El códec pedido por el usuario es %1$s, con una tasa de bits de %2$s. Cualquier cambio en el códec y tasa de bits del archivo en el formato elegido será manejado por el servidor, que puede, o no, soportar esta operación.</string>
|
||||
<string name="track_info_summary_original_file">La aplicación solo leerá el archivo original ofrecido por el servidor. La app pedirá al servidor, de forma explícita, el archivo sin transcodificar con la tasa de bits de origen.</string>
|
||||
<string name="track_info_summary_server_prioritized">La calidad del archivo a reproducir queda a decisión del servidor. La app no forzará la elección del códec y la tasa de bits para ninguna posible transcodificación.</string>
|
||||
<string name="track_info_summary_transcoding_bitrate">La aplicación pedirá al servidor modificar la tasa de bits del archivo. El usuario ha pedido una tasa de bits de %1$s, mientras que el códec del archivo origen se mantendrá sin cambios. Cualquier cambio en la tasa de bits del archivo en el formato seleccionado será realizado por el servidor, que podrá soportar, o no, la operación.</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_success">Se ha añadido a la lista</string>
|
||||
<string name="settings_song_rating">Mostrar valoración de las pistas</string>
|
||||
<string name="home_sync_starred_albums_title">Sincronizar álbumes favoritos</string>
|
||||
<string name="settings_sync_starred_artists_for_offline_use_title">Sincronizar artistas destacados para uso sin conexión</string>
|
||||
<string name="settings_sync_starred_albums_for_offline_use_summary">Si está habilitada, los álbumes favoritos se descargarán para uso sin conexión.</string>
|
||||
<string name="starred_artist_sync_dialog_title">Sincronizar artistas destacados</string>
|
||||
<string name="starred_album_sync_dialog_summary">Descargar los álbumes favoritos puede consumir una gran cantidad de datos.</string>
|
||||
<string name="equalizer_fragment_title">Ecualizador</string>
|
||||
<string name="equalizer_reset">Restablecer</string>
|
||||
<string name="equalizer_enable">Habilitar</string>
|
||||
<string name="equalizer_not_supported">No disponible en este dispositivo</string>
|
||||
<string name="settings_app_equalizer">Ecualizador</string>
|
||||
<string name="settings_app_equalizer_summary">Abrir el ecualizador integrado</string>
|
||||
<string name="settings_download_folder_cleared">Se ha limpiado la carpeta de descargas.</string>
|
||||
<string name="settings_download_folder_set">Se ha establecido la carpeta de descargas</string>
|
||||
<string name="widget_label">Widget de Tempo</string>
|
||||
<string name="widget_not_playing">En pausa</string>
|
||||
<string name="widget_placeholder_subtitle">Abrir Tempo</string>
|
||||
<string name="widget_time_duration_placeholder">0:00</string>
|
||||
<string name="widget_content_desc_album_art">Portada del álbum</string>
|
||||
<string name="widget_content_desc_play_pause">Reproducir o pausar</string>
|
||||
<string name="widget_content_desc_next">Siguiente pista</string>
|
||||
<string name="widget_content_desc_repeat">Cambiar modo de repetición</string>
|
||||
<string name="widget_content_desc_shuffle">Activar/desactivar aleatorio</string>
|
||||
<string name="widget_content_desc_prev">Pista anterior</string>
|
||||
<string name="download_refresh_no_directory">Establece una carpeta de descarga para actualizar tus descargas</string>
|
||||
<string name="home_sync_starred_artists_title">Sincronizar artistas destacados</string>
|
||||
<string name="player_lyrics_download_content_description">Descargar letras para uso sin conexión</string>
|
||||
<string name="player_lyrics_downloaded_content_description">Letras descargadas para uso sin conexión</string>
|
||||
<string name="player_lyrics_download_success">Letra guardada para uso sin conexión</string>
|
||||
<string name="settings_allow_playlist_duplicates">Permitir añadir pistas repetidas a la lista</string>
|
||||
<string name="settings_support_summary">Participa en las discusiones y el soporte de la comunidad</string>
|
||||
<string name="settings_show_mini_shuffle_button">Mostrar el botón «Aleatorio»</string>
|
||||
<string name="settings_auto_download_lyrics">Descargar automáticamente las letras</string>
|
||||
<string name="starred_artist_sync_dialog_summary">Descargar los artistas destacados podría consumir una gran cantidad de datos.</string>
|
||||
<string name="settings_sync_starred_artists_for_offline_use_summary">Si está habilitada, los artistas destacados se descargarán para uso sin conexión.</string>
|
||||
<string name="widget_time_elapsed_placeholder">0:00</string>
|
||||
<string name="exo_controls_heart_off_description">Eliminar de favoritos</string>
|
||||
</resources>
|
||||
@@ -117,6 +117,8 @@
|
||||
<string name="home_sync_starred_download">Télécharger</string>
|
||||
<string name="home_sync_starred_subtitle">Télécharger ces titres peut entraîner une utilisation importante de données</string>
|
||||
<string name="home_sync_starred_title">On dirait qu\'il y a des titres favoris à synchroniser</string>
|
||||
<string name="home_sync_starred_albums_title">Synchroniser les albums favoris</string>
|
||||
<string name="home_sync_starred_albums_subtitle">Les albums marqués d\'une étoile seront disponibles hors-ligne</string>
|
||||
<string name="home_title_best_of">Best of</string>
|
||||
<string name="home_title_discovery">Découverte</string>
|
||||
<string name="home_title_discovery_shuffle_all_button">Tout mélanger</string>
|
||||
@@ -294,8 +296,8 @@
|
||||
<string name="settings_delete_download_storage_summary">Continuer entraînera la suppression irréversible de tous les éléments sauvegardés.</string>
|
||||
<string name="settings_delete_download_storage_title">Supprimer les éléments sauvegardés</string>
|
||||
<string name="settings_download_storage_title">Stockage des téléchargements</string>
|
||||
<string name="settings_equalizer_summary">Ajuster les paramètres audios</string>
|
||||
<string name="settings_equalizer_title">Égaliseur</string>
|
||||
<string name="settings_system_equalizer_summary">Ajuster les paramètres audios</string>
|
||||
<string name="settings_system_equalizer_title">Égaliseur du système</string>
|
||||
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
|
||||
<string name="settings_github_summary">Suivre le développement</string>
|
||||
<string name="settings_github_title">Github</string>
|
||||
@@ -341,7 +343,9 @@
|
||||
<string name="settings_summary_streaming_cache_size">%1$s \nUtilisé actuellement : %2$s MiB</string>
|
||||
<string name="settings_summary_transcoding">Le mode de transcodage à prioriser. Si réglé sur \"Lecture directe\", le débit binaire du fichier ne sera pas modifié.</string>
|
||||
<string name="settings_summary_transcoding_download">Télécharge les médias transcodés. Si activé, les paramètres de transcodage suivants seront utilisés pour les téléchargements.\n\n Si le format de transcodage est reglé à \"Téléchargement direct\", le débit binaire du fichier ne sera pas modifé.</string>
|
||||
<string name="settings_summary_transcoding_estimate_content_length">Quand le fichier est transcodé à la volé, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.</string>
|
||||
<string name="settings_summary_transcoding_estimate_content_length">Quand le fichier est transcodé à la volée, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.</string>
|
||||
<string name="settings_sync_starred_albums_for_offline_use_summary">Si activé, les albums favoris seront téléchargés pour l\'écoute hors-ligne</string>
|
||||
<string name="settings_sync_starred_albums_for_offline_use_title">Synchronisation des albums favoris pour écoute hors-ligne</string>
|
||||
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligne</string>
|
||||
<string name="settings_sync_starred_tracks_for_offline_use_title">Synchronisation des pistes favorites pour écoute hors-ligne</string>
|
||||
<string name="settings_theme">Thème</string>
|
||||
@@ -394,8 +398,10 @@
|
||||
<string name="starred_sync_dialog_negative_button">Annuler</string>
|
||||
<string name="starred_sync_dialog_neutral_button">Continuer</string>
|
||||
<string name="starred_sync_dialog_positive_button">Continuer et télécharger</string>
|
||||
<string name="starred_sync_dialog_summary">Le téléchargement des titres favoris pourrer utiliser beaucoup de données.</string>
|
||||
<string name="starred_sync_dialog_summary">Le téléchargement des titres favoris pourrait consommer beaucoup de données.</string>
|
||||
<string name="starred_sync_dialog_title">Synchroniser les titres favoris</string>
|
||||
<string name="starred_album_sync_dialog_summary">Le téléchargement des titres favoris pourrait consommer beaucoup de données.</string>
|
||||
<string name="starred_album_sync_dialog_title">Synchroniser les albums favoris</string>
|
||||
<string name="streaming_cache_storage_dialog_sub_summary">Veuillez redémarrer l\'app pour appliquer les changements.</string>
|
||||
<string name="streaming_cache_storage_dialog_summary">Modifier le chemin de stockage des fichiers mis en cache risque de provoquer la suppression de tous les fichiers précédemment mis en cache dans le nouvel espace de stockage.</string>
|
||||
<string name="streaming_cache_storage_dialog_title">Sélectionner une option de stockage</string>
|
||||
@@ -430,4 +436,14 @@
|
||||
<string name="undraw_page">unDraw</string>
|
||||
<string name="undraw_thanks">Un grand merci à unDraw, nous n\'aurions pas pu rendre cette application aussi belle sans leurs illustrations.</string>
|
||||
<string name="undraw_url">https://undraw.co/</string>
|
||||
<plurals name="home_sync_starred_albums_count">
|
||||
<item quantity="one">%d album à synchroniser</item>
|
||||
<item quantity="other">%d albums à synchroniser</item>
|
||||
</plurals>
|
||||
<string name="equalizer_fragment_title">Égaliseur</string>
|
||||
<string name="equalizer_reset">Réinitialiser</string>
|
||||
<string name="equalizer_enable">Activer</string>
|
||||
<string name="equalizer_not_supported">Non supporté sur cet appareil</string>
|
||||
<string name="settings_app_equalizer">Égaliseur</string>
|
||||
<string name="settings_app_equalizer_summary">Ouvrir l\'égaliseur intégré</string>
|
||||
</resources>
|
||||
|
||||
@@ -282,8 +282,8 @@
|
||||
<string name="settings_delete_download_storage_summary">Continuando, tutti gli elementi salvati verranno eliminati in modo irreversibile.</string>
|
||||
<string name="settings_delete_download_storage_title">Elimina elementi salvati</string>
|
||||
<string name="settings_download_storage_title">Archivio download</string>
|
||||
<string name="settings_equalizer_summary">Regola le impostazioni audio</string>
|
||||
<string name="settings_equalizer_title">Equalizzatore</string>
|
||||
<string name="settings_system_equalizer_summary">Regola le impostazioni audio</string>
|
||||
<string name="settings_system_equalizer_title">Equalizzatore di sistema</string>
|
||||
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
|
||||
<string name="settings_github_summary">Segui lo sviluppo</string>
|
||||
<string name="settings_github_title">Github</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user