Merge branch 'main' into patch-1

This commit is contained in:
Thomas Anderson 2025-10-17 12:26:42 +03:00 committed by GitHub
commit c028c52576
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 9807 additions and 1722 deletions

View file

@ -3,7 +3,7 @@ name: Github Release Workflow
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
build:
@ -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

3
.gitignore vendored
View file

@ -17,4 +17,5 @@
.vscode/settings.json
# release / debug files
tempus-release-key.jks
app/tempo/
app/tempo/
app/notquitemy/

View file

@ -2,6 +2,92 @@
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
## [3.17.14](https://github.com/eddyizm/tempo/releases/tag/v3.17.14) (2025-10-16)
## What's Changed
* fix: General build warning and playback issues by @le-firehawk in https://github.com/eddyizm/tempo/pull/167
* fix: persist album sort preference by @eddyizm in https://github.com/eddyizm/tempo/pull/168
* Fix album parse empty date field by @eddyizm in https://github.com/eddyizm/tempo/pull/171
* fix: Include shuffle/repeat controls in f-droid build's media notific… by @le-firehawk in https://github.com/eddyizm/tempo/pull/174
* fix: limits image size to prevent widget crash #172 by @eddyizm in https://github.com/eddyizm/tempo/pull/175
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.17.0...v3.17.14
## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10)
## What's Changed
* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160
* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158
* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0
## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08)
## What's Changed
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151
* fix: Re-add new equalizer settings that got lost by @jaime-grj in https://github.com/eddyizm/tempo/pull/153
* chore: removed play variant by @eddyizm in https://github.com/eddyizm/tempo/pull/155
* fix: updating release workflow to account for the 32/64 bit builds an… by @eddyizm in https://github.com/eddyizm/tempo/pull/156
* feat: Show sampling rate and bit depth in downloads by @jaime-grj in https://github.com/eddyizm/tempo/pull/154
* fix: Replace hardcoded strings in SettingsFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/152
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.0...v3.16.6
## [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

View file

@ -24,9 +24,22 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork
sha256 signing key fingerprint
`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)
### Releases
Please note the two variants in the release assets include release/debug and 32/64 bit flavors.
`app-tempo` <- The github release with all the android auto/chromecast features
`app-notquitemy*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github.
As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon.
Moved details to [CHANGELOG.md](CHANGELOG.md)
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
@ -46,13 +59,11 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options.
- **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server.
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.
- **Multiple Libraries**: Tempo handles multi-library setups gracefully. They are displayed as Library folders.
## Sponsors
## Credits
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0)
Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app.
## Screenshot
<p align="center">
@ -87,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l
<img src="mockup/dark/8_screenshot.png" width=200>
</p>
## Contributing
Please fork and open PR's against the development branch. Make sure your PR builds successfully.
If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
Currently there are no tests but I would love to start on some unit tests.
Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
## License
Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.

View file

@ -57,10 +57,30 @@ This app works with any service that implements the Subsonic API, including:
## Main Features
### Library View
**TODO**
**Multi-library**
Tempo handles multi-library setups gracefully. They are displayed as Library folders.
However, if you want to limit or change libraries you could use a workaround, if your server supports it.
You can create multiple users , one for each library, and save each of them in Tempo app.
### Now Playing Screen
**TODO**
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
<p align="left">
<img src="mockup/usage/player_icons.png" width=159>
</p>
*marked the icons with numbers for clarity*
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
2. Adds track to playlist - pops up playlist dialog.
3. Adds tracks to the queue via instant mix function
4. Saves play queue (if the feature is enabled in the settings)
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
## Navigation

View file

@ -10,9 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 31
versionName '3.14.8'
versionCode 36
versionName '3.17.14'
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)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="asset"
android:scheme="tempo" />
</intent-filter>
</activity>
<service
@ -73,5 +83,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>

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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);
}

View file

@ -4,14 +4,18 @@ import android.content.Context;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
import com.bumptech.glide.Registry;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import com.cappielloantonio.tempo.util.Preferences;
import java.io.InputStream;
@GlideModule
public class CustomGlideModule extends AppGlideModule {
@Override
@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule {
builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize));
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
}
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory());
}
}

View file

@ -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,9 +111,21 @@ 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;
private String item;
private Builder(Context context, String item, ResourceType type) {
this.requestManager = Glide.with(context);

View file

@ -0,0 +1,110 @@
package com.cappielloantonio.tempo.glide;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.data.DataFetcher;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
private static final int DEFAULT_TIMEOUT_MS = 2500;
@Override
public boolean handles(@NonNull String model) {
return model.startsWith("http://") || model.startsWith("https://");
}
@Override
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
if (!handles(model)) {
return null;
}
return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model));
}
private static class IPv6StreamFetcher implements DataFetcher<InputStream> {
private final String model;
private InputStream stream;
private HttpURLConnection connection;
IPv6StreamFetcher(String model) {
this.model = model;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
try {
URL url = new URL(model);
connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(DEFAULT_TIMEOUT_MS);
connection.setReadTimeout(DEFAULT_TIMEOUT_MS);
connection.setUseCaches(true);
connection.setDoInput(true);
connection.connect();
if (connection.getResponseCode() / 100 != 2) {
callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode()));
return;
}
stream = connection.getInputStream();
callback.onDataReady(stream);
} catch (IOException e) {
callback.onLoadFailed(e);
}
}
@Override
public void cleanup() {
if (stream != null) {
try {
stream.close();
} catch (IOException ignored) {
}
}
if (connection != null) {
connection.disconnect();
}
}
@Override
public void cancel() {
// HttpURLConnection does not provide a direct cancel mechanism.
}
@NonNull
@Override
public Class<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.REMOTE;
}
}
public static class Factory implements ModelLoaderFactory<String, InputStream> {
@NonNull
@Override
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new IPv6StringLoader();
}
@Override
public void teardown() {
// No-op
}
}
}

View file

@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
import com.cappielloantonio.tempo.subsonic.models.Child
import com.cappielloantonio.tempo.util.Preferences
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
@Entity(tableName = "chronology")
class Chronology(@PrimaryKey override val id: String) : Child(id) {
class Chronology(
@PrimaryKey override val id: String,
@ColumnInfo(name = "timestamp")
var timestamp: Long = System.currentTimeMillis()
var timestamp: Long = System.currentTimeMillis(),
@ColumnInfo(name = "server")
var server: String? = null
var server: String? = null,
) : Child(id) {
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")

View file

@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
@Entity(tableName = "download")
class Download(@PrimaryKey override val id: String) : Child(id) {
class Download(
@PrimaryKey override val id: String,
@ColumnInfo(name = "playlist_id")
var playlistId: String? = null
var playlistId: String? = null,
@ColumnInfo(name = "playlist_name")
var playlistName: String? = null
var playlistName: String? = null,
@ColumnInfo(name = "download_state", defaultValue = "1")
var downloadState: Int = 0
var downloadState: Int = 0,
@ColumnInfo(name = "download_uri", defaultValue = "")
var downloadUri: String? = null
var downloadUri: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) {
parentId = child.parentId
isDir = child.isDir
@ -40,6 +38,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
@ -60,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
@Keep
data class DownloadStack(
var id: String,
var view: String?
var view: String?,
)

View file

@ -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()
)

View file

@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
@Entity(tableName = "queue")
class Queue(override val id: String) : Child(id) {
class Queue(
override val id: String,
@PrimaryKey
@ColumnInfo(name = "track_order")
var trackOrder: Int = 0
var trackOrder: Int = 0,
@ColumnInfo(name = "last_play")
var lastPlay: Long = 0
var lastPlay: Long = 0,
@ColumnInfo(name = "playing_changed")
var playingChanged: Long = 0
var playingChanged: Long = 0,
@ColumnInfo(name = "stream_id")
var streamId: String? = null
var streamId: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) {
parentId = child.parentId
isDir = child.isDir

View file

@ -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)

View file

@ -2,6 +2,7 @@ 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.interfaces.DecadesCallback;
@ -31,14 +32,22 @@ public class AlbumRepository {
.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().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
if (response.isSuccessful()
&& response.body() != null
&& response.body().getSubsonicResponse().getAlbumList2() != null
&& response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
} else {
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
listLiveAlbums.setValue(new ArrayList<>());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
listLiveAlbums.setValue(new ArrayList<>());
}
});

View file

@ -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();

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -80,21 +80,52 @@ public class PlaylistRepository {
return listLivePlaylistSongs;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
public MutableLiveData<Playlist> getPlaylist(String id) {
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.getPlaylist(id)
.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 (response.isSuccessful()
&& response.body() != null
&& response.body().getSubsonicResponse().getPlaylist() != null) {
playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist());
} else {
playlistLiveData.setValue(null);
}
}
@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();
playlistLiveData.setValue(null);
}
});
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
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();
}
});
}
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
@ -131,23 +162,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()

View file

@ -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
}
}

View file

@ -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(() -> {

View file

@ -2,22 +2,28 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.google.gson.GsonBuilder
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.Date
import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit
init {
val gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter())
.setLenient()
.create()
retrofit = Retrofit.Builder()
.baseUrl(subsonic.url)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create()))
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
.addConverterFactory(GsonConverterFactory.create(gson))
.client(getOkHttpClient())
.build()
}

View file

@ -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() {

View file

@ -4,38 +4,36 @@ import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import java.time.Instant
import java.time.LocalDate
import java.util.*
import java.util.Date
@Keep
@Parcelize
open class AlbumID3 : Parcelable {
var id: String? = null
var name: String? = null
var artist: String? = null
var artistId: String? = null
open class AlbumID3(
var id: String? = null,
var name: String? = null,
var artist: String? = null,
var artistId: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var songCount: Int? = 0
var duration: Int? = 0
var playCount: Long? = 0
var created: Date? = null
var starred: Date? = null
var year: Int = 0
var genre: String? = null
var played: Date? = Date(0)
var userRating: Int? = 0
var recordLabels: List<RecordLabel>? = null
var musicBrainzId: String? = null
var genres: List<ItemGenre>? = null
var artists: List<ArtistID3>? = null
var displayArtist: String? = null
var releaseTypes: List<String>? = null
var moods: List<String>? = null
var sortName: String? = null
var originalReleaseDate: ItemDate? = null
var releaseDate: ItemDate? = null
var isCompilation: Boolean? = null
var discTitles: List<DiscTitle>? = null
}
var coverArtId: String? = null,
var songCount: Int? = 0,
var duration: Int? = 0,
var playCount: Long? = 0,
var created: Date? = null,
var starred: Date? = null,
var year: Int = 0,
var genre: String? = null,
var played: Date? = Date(0),
var userRating: Int? = 0,
var recordLabels: List<RecordLabel>? = null,
var musicBrainzId: String? = null,
var genres: List<ItemGenre>? = null,
var artists: List<ArtistID3>? = null,
var displayArtist: String? = null,
var releaseTypes: List<String>? = null,
var moods: List<String>? = null,
var sortName: String? = null,
var originalReleaseDate: ItemDate? = null,
var releaseDate: ItemDate? = null,
var isCompilation: Boolean? = null,
var discTitles: List<DiscTitle>? = null,
) : Parcelable

View file

@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class AlbumWithSongsID3 : AlbumID3(), Parcelable {
class AlbumWithSongsID3(
@SerializedName("song")
var songs: List<Child>? = null
}
var songs: List<Child>? = null,
) : AlbumID3(), Parcelable

View file

@ -7,10 +7,10 @@ import java.util.Date
@Keep
@Parcelize
class Artist : Parcelable {
var id: String? = null
var name: String? = null
var starred: Date? = null
var userRating: Int? = null
var averageRating: Double? = null
}
class Artist(
var id: String? = null,
var name: String? = null,
var starred: Date? = null,
var userRating: Int? = null,
var averageRating: Double? = null,
) : Parcelable

View file

@ -4,15 +4,15 @@ import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
open class ArtistID3 : Parcelable {
var id: String? = null
var name: String? = null
open class ArtistID3(
var id: String? = null,
var name: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var albumCount = 0
var starred: Date? = null
}
var coverArtId: String? = null,
var albumCount: Int = 0,
var starred: Date? = null,
) : Parcelable

View file

@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
class ArtistWithAlbumsID3(
@SerializedName("album")
var albums: List<AlbumID3>? = null
}
var albums: List<AlbumID3>? = null,
) : ArtistID3(), Parcelable

View file

@ -8,15 +8,15 @@ import java.util.Date
@Keep
@Parcelize
class Directory : Parcelable {
class Directory(
@SerializedName("child")
var children: List<Child>? = null
var id: String? = null
var children: List<Child>? = null,
var id: String? = null,
@SerializedName("parent")
var parentId: String? = null
var name: String? = null
var starred: Date? = null
var userRating: Int? = null
var averageRating: Double? = null
var playCount: Long? = null
}
var parentId: String? = null,
var name: String? = null,
var starred: Date? = null,
var userRating: Int? = null,
var averageRating: Double? = null,
var playCount: Long? = null,
) : Parcelable

View file

@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class DiscTitle : Parcelable {
var disc: Int? = null
var title: String? = null
}
open class DiscTitle(
var disc: Int? = null,
var title: String? = null,
) : Parcelable

View file

@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class Genre : Parcelable {
class Genre(
@SerializedName("value")
var genre: String? = null
var songCount = 0
var albumCount = 0
}
var genre: String? = null,
var songCount: Int = 0,
var albumCount: Int = 0,
) : Parcelable

View file

@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class InternetRadioStation : Parcelable {
var id: String? = null
var name: String? = null
var streamUrl: String? = null
var homePageUrl: String? = null
}
class InternetRadioStation(
var id: String? = null,
var name: String? = null,
var streamUrl: String? = null,
var homePageUrl: String? = null,
) : Parcelable

View file

@ -9,11 +9,11 @@ import java.util.Locale
@Keep
@Parcelize
open class ItemDate : Parcelable {
var year: Int? = null
var month: Int? = null
var day: Int? = null
open class ItemDate(
var year: Int? = null,
var month: Int? = null,
var day: Int? = null,
) : Parcelable {
fun getFormattedDate(): String? {
if (year == null && month == null && day == null) return null
@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
SimpleDateFormat("yyyy", Locale.getDefault())
} else if (day == null) {
SimpleDateFormat("MMMM yyyy", Locale.getDefault())
}
else{
} else {
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
}

View file

@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class ItemGenre : Parcelable {
var name: String? = null
}
open class ItemGenre(
var name: String? = null,
) : Parcelable

View file

@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class MusicFolder : Parcelable {
var id: String? = null
var name: String? = null
}
class MusicFolder(
var id: String? = null,
var name: String? = null,
) : Parcelable

View file

@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize
class NowPlayingEntry(
@SerializedName("_id")
override val id: String
) : Child(id) {
var username: String? = null
var minutesAgo = 0
var playerId = 0
var playerName: String? = null
}
override val id: String,
var username: String? = null,
var minutesAgo: Int = 0,
var playerId: Int = 0,
var playerName: String? = null,
) : Child(id)

View file

@ -7,8 +7,9 @@ import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
@ -16,27 +17,56 @@ import java.util.*
open class Playlist(
@PrimaryKey
@ColumnInfo(name = "id")
open var id: String
) : Parcelable {
open var id: String,
@ColumnInfo(name = "name")
var name: String? = null
var name: String? = null,
@ColumnInfo(name = "duration")
var duration: Long = 0,
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null,
) : Parcelable {
@Ignore
@IgnoredOnParcel
var comment: String? = null
@Ignore
@IgnoredOnParcel
var owner: String? = null
@Ignore
@IgnoredOnParcel
@SerializedName("public")
var isUniversal: Boolean? = null
@Ignore
@IgnoredOnParcel
var songCount: Int = 0
@ColumnInfo(name = "duration")
var duration: Long = 0
@Ignore
@IgnoredOnParcel
var created: Date? = null
@Ignore
@IgnoredOnParcel
var changed: Date? = null
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null
@Ignore
@IgnoredOnParcel
var allowedUsers: List<String>? = null
@Ignore
constructor(
id: String,
name: String?,
comment: String?,
owner: String?,
isUniversal: Boolean?,
songCount: Int,
duration: Long,
created: Date?,
changed: Date?,
coverArtId: String?,
allowedUsers: List<String>?,
) : this(id, name, duration, coverArtId) {
this.comment = comment
this.owner = owner
this.isUniversal = isUniversal
this.songCount = songCount
this.created = created
this.changed = changed
this.allowedUsers = allowedUsers
}
}

View file

@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
class PlaylistWithSongs(
@SerializedName("_id")
override var id: String
) : Playlist(id), Parcelable {
override var id: String,
@SerializedName("entry")
var entries: List<Child>? = null
}
var entries: List<Child>? = null,
) : Playlist(id), Parcelable

View file

@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
@Keep
class Playlists(
@SerializedName("playlist")
var playlists: List<Playlist>? = null
var playlists: List<Playlist>? = null,
)

View file

@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class PodcastChannel : Parcelable {
class PodcastChannel(
@SerializedName("episode")
var episodes: List<PodcastEpisode>? = null
var id: String? = null
var url: String? = null
var title: String? = null
var description: String? = null
var episodes: List<PodcastEpisode>? = null,
var id: String? = null,
var url: String? = null,
var title: String? = null,
var description: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var originalImageUrl: String? = null
var status: String? = null
var errorMessage: String? = null
}
var coverArtId: String? = null,
var originalImageUrl: String? = null,
var status: String? = null,
var errorMessage: String? = null,
) : Parcelable

View file

@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
@Keep
@Parcelize
class PodcastEpisode : Parcelable {
var id: String? = null
class PodcastEpisode(
var id: String? = null,
@SerializedName("parent")
var parentId: String? = null
var isDir = false
var title: String? = null
var album: String? = null
var artist: String? = null
var year: Int? = null
var genre: String? = null
var parentId: String? = null,
var isDir: Boolean = false,
var title: String? = null,
var album: String? = null,
var artist: String? = null,
var year: Int? = null,
var genre: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var size: Long? = null
var contentType: String? = null
var suffix: String? = null
var duration: Int? = null
var coverArtId: String? = null,
var size: Long? = null,
var contentType: String? = null,
var suffix: String? = null,
var duration: Int? = null,
@SerializedName("bitRate")
var bitrate: Int? = null
var path: String? = null
var isVideo: Boolean = false
var created: Date? = null
var artistId: String? = null
var type: String? = null
var streamId: String? = null
var channelId: String? = null
var description: String? = null
var status: String? = null
var publishDate: Date? = null
}
var bitrate: Int? = null,
var path: String? = null,
var isVideo: Boolean = false,
var created: Date? = null,
var artistId: String? = null,
var type: String? = null,
var streamId: String? = null,
var channelId: String? = null,
var description: String? = null,
var status: String? = null,
var publishDate: Date? = null,
) : Parcelable

View file

@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class RecordLabel : Parcelable {
var name: String? = null
}
open class RecordLabel(
var name: String? = null,
) : Parcelable

View file

@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
class Share : Parcelable {
data class Share(
@SerializedName("entry")
var entries: List<Child>? = null
var id: String? = null
var url: String? = null
var description: String? = null
var username: String? = null
var created: Date? = null
var expires: Date? = null
var lastVisited: Date? = null
var visitCount = 0
}
var entries: List<Child>? = null,
var id: String? = null,
var url: String? = null,
var description: String? = null,
var username: String? = null,
var created: Date? = null,
var expires: Date? = null,
var lastVisited: Date? = null,
var visitCount: Int = 0
) : Parcelable

View file

@ -0,0 +1,42 @@
package com.cappielloantonio.tempo.subsonic.utils
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
// This adapter handles Date objects, returning null if the JSON string is empty or unparsable.
class EmptyDateTypeAdapter : JsonDeserializer<Date> {
// Define the date formats expected from the Subsonic server.
private val dateFormats: List<SimpleDateFormat> = listOf(
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
)
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? {
val jsonString = json.asString.trim()
if (jsonString.isEmpty()) {
return null
}
for (format in dateFormats) {
try {
return format.parse(jsonString)
} catch (e: ParseException) {
// Ignore and try the next format
}
}
return null
}
}

View file

@ -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;
@ -31,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment;
import com.cappielloantonio.tempo.util.AssetLinkNavigator;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
@ -54,8 +62,11 @@ public class MainActivity extends BaseActivity {
private BottomNavigationView bottomNavigationView;
public NavController navController;
private BottomSheetBehavior bottomSheetBehavior;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -69,6 +80,7 @@ public class MainActivity extends BaseActivity {
setContentView(view);
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
assetLinkNavigator = new AssetLinkNavigator(this);
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
connectivityStatusReceiverManager(true);
@ -77,12 +89,16 @@ public class MainActivity extends BaseActivity {
checkConnectionType();
getOpenSubsonicExtensions();
checkTempoUpdate();
maybeSchedulePlaybackIntent(getIntent());
}
@Override
protected void onStart() {
super.onStart();
pingServer();
initService();
consumePendingPlaybackIntent();
}
@Override
@ -98,6 +114,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)
@ -292,6 +316,24 @@ public class MainActivity extends BaseActivity {
public void goFromLogin() {
setBottomSheetInPeek(mainViewModel.isQueueLoaded());
goToHome();
consumePendingAssetLink();
}
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) {
openAssetLink(assetLink, true);
}
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) {
if (!isUserAuthenticated()) {
pendingAssetLink = assetLink;
return;
}
if (collapsePlayer) {
setBottomSheetInPeek(true);
}
if (assetLinkNavigator != null) {
assetLinkNavigator.open(assetLink);
}
}
public void quit() {
@ -351,6 +393,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
}
@ -361,6 +404,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
mainViewModel.ping().observe(this, subsonicResponse -> {
if (subsonicResponse == null) {
@ -376,6 +420,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 +459,98 @@ 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);
}
handleAssetLinkIntent(intent);
}
private void consumePendingPlaybackIntent() {
if (pendingDownloadPlaybackIntent == null) return;
Intent intent = pendingDownloadPlaybackIntent;
pendingDownloadPlaybackIntent = null;
playDownloadedMedia(intent);
}
private void handleAssetLinkIntent(Intent intent) {
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent);
if (assetLink == null) {
return;
}
if (!isUserAuthenticated()) {
pendingAssetLink = assetLink;
intent.setData(null);
return;
}
if (assetLinkNavigator != null) {
assetLinkNavigator.open(assetLink);
}
intent.setData(null);
}
private boolean isUserAuthenticated() {
return Preferences.getPassword() != null
|| (Preferences.getToken() != null && Preferences.getSalt() != null);
}
private void consumePendingAssetLink() {
if (pendingAssetLink == null || assetLinkNavigator == null) {
return;
}
assetLinkNavigator.open(pendingAssetLink);
pendingAssetLink = null;
}
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);
}
}

View file

@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
}
public void sort(String order) {
if (albums == null) return;
switch (order) {
case Constants.ALBUM_ORDER_BY_NAME:
albums.sort(Comparator.comparing(AlbumID3::getName));
albums.sort(Comparator.comparing(
album -> album.getName() != null ? album.getName() : "",
String.CASE_INSENSITIVE_ORDER
));
break;
case Constants.ALBUM_ORDER_BY_ARTIST:
albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder())));
albums.sort(Comparator.comparing(
album -> album.getArtist() != null ? album.getArtist() : "",
String.CASE_INSENSITIVE_ORDER
));
break;
case Constants.ALBUM_ORDER_BY_YEAR:
albums.sort(Comparator.comparing(AlbumID3::getYear));
@ -166,15 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
Collections.shuffle(albums);
break;
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
albums.sort(Comparator.comparing(AlbumID3::getCreated));
albums.sort(Comparator.comparing(
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
Comparator.nullsLast(Date::compareTo)
));
Collections.reverse(albums);
break;
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
albums.sort(Comparator.comparing(AlbumID3::getPlayed));
albums.sort(Comparator.comparing(
album -> album.getPlayed() != null ? album.getPlayed() : new Date(0),
Comparator.nullsLast(Date::compareTo)
));
Collections.reverse(albums);
break;
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
albums.sort(Comparator.comparing(AlbumID3::getPlayCount));
albums.sort(Comparator.comparing(
album -> album.getPlayCount() != null ? album.getPlayCount() : 0L
));
Collections.reverse(albums);
break;
}

View file

@ -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)
)
);

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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();
});

View file

@ -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();
}
}

View file

@ -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();
});
}
}
}

View file

@ -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();
}

View file

@ -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();
});
}
}
}

View file

@ -61,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())

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment {
private DialogTrackInfoBinding bind;
private final MediaMetadata mediaMetadata;
private AssetLinkUtil.AssetLink songLink;
private AssetLinkUtil.AssetLink albumLink;
private AssetLinkUtil.AssetLink artistLink;
private AssetLinkUtil.AssetLink genreLink;
private AssetLinkUtil.AssetLink yearLink;
public TrackInfoDialog(MediaMetadata mediaMetadata) {
this.mediaMetadata = mediaMetadata;
@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment {
}
private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment {
: "");
if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
albumLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ALBUM, mediaMetadata.extras.getString("albumId"));
artistLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, mediaMetadata.extras.getString("artistId"));
genreLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkGenre"));
yearLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkYear"));
CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song)
.build()
.into(bind.trackCoverInfoImageView);
bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)));
bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)));
bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)));
bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink);
bindAssetLink(bind.trakTitleInfoTextView, songLink);
bindAssetLink(bind.trakArtistInfoTextView, artistLink != null ? artistLink : songLink);
String titleValue = mediaMetadata.extras.getString("title", getString(R.string.label_placeholder));
String albumValue = mediaMetadata.extras.getString("album", getString(R.string.label_placeholder));
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0);
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);
}
if (yearLink == null && yearValue != 0) {
yearLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(yearValue));
}
bind.titleValueSector.setText(titleValue);
bind.albumValueSector.setText(albumValue);
bind.artistValueSector.setText(artistValue);
bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder));
bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)));
bind.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(genreValue);
bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder));
bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder)));
bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder)));
@ -83,6 +116,12 @@ public class TrackInfoDialog extends DialogFragment {
bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder));
bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder)));
bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder));
bindAssetLink(bind.titleValueSector, songLink);
bindAssetLink(bind.albumValueSector, albumLink);
bindAssetLink(bind.artistValueSector, artistLink);
bindAssetLink(bind.genreValueSector, genreLink);
bindAssetLink(bind.yearValueSector, yearLink);
}
}
@ -135,4 +174,31 @@ public class TrackInfoDialog extends DialogFragment {
bind.trakTranscodingInfoTextView.setText(info);
}
}
private void bindAssetLink(android.view.View view, AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> {
dismissAllowingStateLoss();
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
((com.cappielloantonio.tempo.ui.activity.MainActivity) requireActivity()).openAssetLink(assetLink, collapse);
});
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
}

View file

@ -32,17 +32,19 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class)
public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private static final String TAG = "ArtistCatalogueFragment";
private static final String TAG = "AlbumCatalogueFragment";
private FragmentAlbumCatalogueBinding bind;
private MainActivity activity;
private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter;
private String currentSortOrder;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@ -115,7 +117,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
albumAdapter = new AlbumCatalogueAdapter(this, true);
albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
bind.albumCatalogueRecyclerView.setAdapter(albumAdapter);
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> albumAdapter.setItems(albums));
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> {
albumAdapter.setItems(albums);
applySavedSortOrder();
});
bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v);
@ -137,6 +142,44 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
});
}
private void applySavedSortOrder() {
String savedSortOrder = Preferences.getAlbumSortOrder();
currentSortOrder = savedSortOrder;
albumAdapter.sort(savedSortOrder);
updateSortIndicator();
}
private void updateSortIndicator() {
if (bind == null) return;
String sortText = getSortDisplayText(currentSortOrder);
bind.albumListSortTextView.setText(sortText);
bind.albumListSortTextView.setVisibility(View.VISIBLE);
}
private String getSortDisplayText(String sortOrder) {
if (sortOrder == null) return "";
switch (sortOrder) {
case Constants.ALBUM_ORDER_BY_NAME:
return getString(R.string.menu_sort_name);
case Constants.ALBUM_ORDER_BY_ARTIST:
return getString(R.string.menu_group_by_artist);
case Constants.ALBUM_ORDER_BY_YEAR:
return getString(R.string.menu_sort_year);
case Constants.ALBUM_ORDER_BY_RANDOM:
return getString(R.string.menu_sort_random);
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
return getString(R.string.menu_sort_recently_added);
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
return getString(R.string.menu_sort_recently_played);
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
return getString(R.string.menu_sort_most_played);
default:
return "";
}
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.toolbar_menu, menu);
@ -172,26 +215,29 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
popup.getMenuInflater().inflate(menuResource, popup.getMenu());
popup.setOnMenuItemClickListener(menuItem -> {
String newSortOrder = null;
if (menuItem.getItemId() == R.id.menu_album_sort_name) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_NAME;
} else if (menuItem.getItemId() == R.id.menu_album_sort_artist) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST;
} else if (menuItem.getItemId() == R.id.menu_album_sort_year) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_YEAR;
} else if (menuItem.getItemId() == R.id.menu_album_sort_random) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED;
} else if (menuItem.getItemId() == R.id.menu_album_sort_most_played) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_MOST_PLAYED);
newSortOrder = Constants.ALBUM_ORDER_BY_MOST_PLAYED;
}
if (newSortOrder != null) {
currentSortOrder = newSortOrder;
albumAdapter.sort(newSortOrder);
Preferences.setAlbumSortOrder(newSortOrder);
updateSortIndicator();
return true;
}

View file

@ -35,11 +35,15 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
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 +56,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 +79,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 +97,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 +133,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;
}
@ -157,8 +178,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.albumNameLabel.setText(album.getName());
bind.albumArtistLabel.setText(album.getArtist());
AssetLinkUtil.applyLinkAppearance(bind.albumArtistLabel);
AssetLinkUtil.AssetLink artistLink = buildArtistLink(album);
bind.albumArtistLabel.setOnLongClickListener(v -> {
if (artistLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), artistLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, artistLink.id), Toast.LENGTH_SHORT).show();
return true;
}
return false;
});
bind.albumReleaseYearLabel.setText(album.getYear() != 0 ? String.valueOf(album.getYear()) : "");
bind.albumReleaseYearLabel.setVisibility(album.getYear() != 0 ? View.VISIBLE : View.GONE);
if (album.getYear() != 0) {
bind.albumReleaseYearLabel.setVisibility(View.VISIBLE);
AssetLinkUtil.applyLinkAppearance(bind.albumReleaseYearLabel);
bind.albumReleaseYearLabel.setOnClickListener(v -> openYearLink(album.getYear()));
bind.albumReleaseYearLabel.setOnLongClickListener(v -> {
AssetLinkUtil.AssetLink yearLink = buildYearLink(album.getYear());
if (yearLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), yearLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, yearLink.id), Toast.LENGTH_SHORT).show();
}
return true;
});
} else {
bind.albumReleaseYearLabel.setVisibility(View.GONE);
bind.albumReleaseYearLabel.setOnClickListener(null);
bind.albumReleaseYearLabel.setOnLongClickListener(null);
AssetLinkUtil.clearLinkAppearance(bind.albumReleaseYearLabel);
}
bind.albumSongCountDurationTextview.setText(getString(R.string.album_page_tracks_count_and_duration, album.getSongCount(), album.getDuration() != null ? album.getDuration() / 60 : 0));
if (album.getGenre() != null && !album.getGenre().isEmpty()) {
bind.albumGenresTextview.setText(album.getGenre());
@ -269,10 +317,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 +348,50 @@ 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);
}
private void openYearLink(int year) {
AssetLinkUtil.AssetLink link = buildYearLink(year);
if (link != null) {
activity.openAssetLink(link);
}
}
private AssetLinkUtil.AssetLink buildYearLink(int year) {
if (year <= 0) return null;
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year));
}
private AssetLinkUtil.AssetLink buildArtistLink(AlbumID3 album) {
if (album == null || album.getArtistId() == null || album.getArtistId().isEmpty()) {
return null;
}
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, album.getArtistId());
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}
});
}

View file

@ -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();
}
}
}
}

View file

@ -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()

View file

@ -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);
}
}

View file

@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment {
}
}
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
if (mediaBrowser.getMediaMetadata().extras != null) {
switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) {

View file

@ -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;
@ -9,9 +13,10 @@ import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.RatingBar;
import android.widget.TextView;
import android.widget.ToggleButton;
import android.widget.RatingBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
@ -24,22 +29,27 @@ 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;
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.RatingViewModel;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@ -68,11 +78,19 @@ public class PlayerControllerFragment extends Fragment {
private ImageButton playerOpenQueueButton;
private ImageButton playerTrackInfo;
private LinearLayout ratingContainer;
private ImageButton equalizerButton;
private ChipGroup assetLinkChipGroup;
private Chip playerSongLinkChip;
private Chip playerAlbumLinkChip;
private Chip playerArtistLinkChip;
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 +107,7 @@ public class PlayerControllerFragment extends Fragment {
initMediaListenable();
initMediaLabelButton();
initArtistLabelButton();
initEqualizerButton();
return view;
}
@ -126,6 +145,11 @@ 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);
assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group);
playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip);
playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip);
playerArtistLinkChip = bind.getRoot().findViewById(R.id.asset_link_artist_chip);
checkAndSetRatingContainerVisibility();
}
@ -206,6 +230,8 @@ public class PlayerControllerFragment extends Fragment {
|| mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null
? View.VISIBLE
: View.GONE);
updateAssetLinkChips(mediaMetadata);
}
private void setMediaInfo(MediaMetadata mediaMetadata) {
@ -246,6 +272,110 @@ public class PlayerControllerFragment extends Fragment {
});
}
private void updateAssetLinkChips(MediaMetadata mediaMetadata) {
if (assetLinkChipGroup == null) return;
String mediaType = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type", Constants.MEDIA_TYPE_MUSIC) : Constants.MEDIA_TYPE_MUSIC;
if (!Constants.MEDIA_TYPE_MUSIC.equals(mediaType)) {
clearAssetLinkChip(playerSongLinkChip);
clearAssetLinkChip(playerAlbumLinkChip);
clearAssetLinkChip(playerArtistLinkChip);
syncAssetLinkGroupVisibility();
return;
}
String songId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("id") : null;
String albumId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("albumId") : null;
String artistId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("artistId") : null;
AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId);
AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId);
AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId);
bindAssetLinkView(playerMediaTitleLabel, songLink);
bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink);
bindAssetLinkView(playerMediaCoverViewPager, songLink);
syncAssetLinkGroupVisibility();
}
private AssetLinkUtil.AssetLink bindAssetLinkChip(Chip chip, String type, String id) {
if (chip == null) return null;
if (TextUtils.isEmpty(id)) {
clearAssetLinkChip(chip);
return null;
}
String label = getString(AssetLinkUtil.getLabelRes(type));
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id);
if (assetLink == null) {
clearAssetLinkChip(chip);
return null;
}
chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id));
chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> {
if (assetLink != null) {
activity.openAssetLink(assetLink);
}
});
chip.setOnLongClickListener(v -> {
if (assetLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
}
return true;
});
return assetLink;
}
private void clearAssetLinkChip(Chip chip) {
if (chip == null) return;
chip.setVisibility(View.GONE);
chip.setText("");
chip.setOnClickListener(null);
chip.setOnLongClickListener(null);
}
private void bindAssetLinkView(View view, AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> {
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
activity.openAssetLink(assetLink, collapse);
});
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
private void syncAssetLinkGroupVisibility() {
if (assetLinkChipGroup == null) return;
boolean hasVisible = false;
for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) {
View child = assetLinkChipGroup.getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
hasVisible = true;
break;
}
}
assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE);
}
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
initPlaybackSpeedButton(mediaBrowser);
@ -426,6 +556,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 +603,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;
}
}
}

View file

@ -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 -> {

View file

@ -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();

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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,6 +24,9 @@ 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;
@ -28,15 +37,19 @@ 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;
@ -49,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();
}
}
}
});
}
@ -86,9 +125,10 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onResume() {
super.onResume();
checkEqualizer();
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
@ -98,10 +138,17 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
actionAutoDownloadLyrics();
actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
}
@Override
@ -124,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;
@ -133,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 {
@ -150,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);
@ -166,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");
@ -245,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();
}
});
@ -281,7 +361,21 @@ public class SettingsFragment extends PreferenceFragmentCompat {
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() {
@ -306,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);
@ -318,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();
@ -326,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
@ -335,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();
}
});
@ -353,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;
}
}
}

View file

@ -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);
}
}

View file

@ -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() {

View file

@ -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);

View file

@ -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();
});

View file

@ -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;
@ -29,16 +30,24 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
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.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
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 +57,16 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song;
private TextView downloadButton;
private TextView removeButton;
private ChipGroup assetLinkChipGroup;
private Chip songLinkChip;
private Chip albumLinkChip;
private Chip artistLinkChip;
private AssetLinkUtil.AssetLink currentSongLink;
private AssetLinkUtil.AssetLink currentAlbumLink;
private AssetLinkUtil.AssetLink currentArtistLink;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@ -66,6 +85,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();
@ -94,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView artistSong = view.findViewById(R.id.song_artist_text_view);
artistSong.setText(songBottomSheetViewModel.getSong().getArtist());
initAssetLinkChips(view);
bindAssetLinkView(coverSong, currentSongLink);
bindAssetLinkView(titleSong, currentSongLink);
bindAssetLinkView(artistSong, currentArtistLink != null ? currentArtistLink : currentSongLink);
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
@ -157,25 +187,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,13 +281,109 @@ 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);
} else {
download.setVisibility(View.VISIBLE);
remove.setVisibility(View.GONE);
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 {
boolean hasLocal = ExternalAudioReader.getUri(song) != null;
downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
}
}
private void initAssetLinkChips(View root) {
assetLinkChipGroup = root.findViewById(R.id.asset_link_chip_group);
songLinkChip = root.findViewById(R.id.asset_link_song_chip);
albumLinkChip = root.findViewById(R.id.asset_link_album_chip);
artistLinkChip = root.findViewById(R.id.asset_link_artist_chip);
currentSongLink = bindAssetLinkChip(songLinkChip, AssetLinkUtil.TYPE_SONG, song.getId());
currentAlbumLink = bindAssetLinkChip(albumLinkChip, AssetLinkUtil.TYPE_ALBUM, song.getAlbumId());
currentArtistLink = bindAssetLinkChip(artistLinkChip, AssetLinkUtil.TYPE_ARTIST, song.getArtistId());
syncAssetLinkGroupVisibility();
}
private AssetLinkUtil.AssetLink bindAssetLinkChip(@Nullable Chip chip, String type, @Nullable String id) {
if (chip == null) return null;
if (id == null || id.isEmpty()) {
clearAssetLinkChip(chip);
return null;
}
String label = getString(AssetLinkUtil.getLabelRes(type));
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id);
if (assetLink == null) {
clearAssetLinkChip(chip);
return null;
}
chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id));
chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> {
if (assetLink != null) {
((MainActivity) requireActivity()).openAssetLink(assetLink);
}
});
chip.setOnLongClickListener(v -> {
if (assetLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
}
return true;
});
return assetLink;
}
private void clearAssetLinkChip(@Nullable Chip chip) {
if (chip == null) return;
chip.setVisibility(View.GONE);
chip.setText("");
chip.setOnClickListener(null);
chip.setOnLongClickListener(null);
}
private void syncAssetLinkGroupVisibility() {
if (assetLinkChipGroup == null) return;
boolean hasVisible = false;
for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) {
View child = assetLinkChipGroup.getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
hasVisible = true;
break;
}
}
assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE);
}
private void bindAssetLinkView(@Nullable View view, @Nullable AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> ((MainActivity) requireActivity()).openAssetLink(assetLink, !AssetLinkUtil.TYPE_SONG.equals(assetLink.type)));
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
private void initializeMediaBrowser() {
@ -263,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() {
homeViewModel.refreshShares(requireActivity());
}
}
}

View file

@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.Genre;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
public final class AssetLinkNavigator {
private final MainActivity activity;
private final SongRepository songRepository = new SongRepository();
private final AlbumRepository albumRepository = new AlbumRepository();
private final ArtistRepository artistRepository = new ArtistRepository();
private final PlaylistRepository playlistRepository = new PlaylistRepository();
public AssetLinkNavigator(@NonNull MainActivity activity) {
this.activity = activity;
}
public void open(@Nullable AssetLinkUtil.AssetLink assetLink) {
if (assetLink == null) {
return;
}
switch (assetLink.type) {
case AssetLinkUtil.TYPE_SONG:
openSong(assetLink.id);
break;
case AssetLinkUtil.TYPE_ALBUM:
openAlbum(assetLink.id);
break;
case AssetLinkUtil.TYPE_ARTIST:
openArtist(assetLink.id);
break;
case AssetLinkUtil.TYPE_PLAYLIST:
openPlaylist(assetLink.id);
break;
case AssetLinkUtil.TYPE_GENRE:
openGenre(assetLink.id);
break;
case AssetLinkUtil.TYPE_YEAR:
openYear(assetLink.id);
break;
default:
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
break;
}
}
private void openSong(@NonNull String id) {
MutableLiveData<Child> liveData = songRepository.getSong(id);
Observer<Child> observer = new Observer<Child>() {
@Override
public void onChanged(Child child) {
liveData.removeObserver(this);
if (child == null) {
Toast.makeText(activity, R.string.asset_link_error_song, Toast.LENGTH_SHORT).show();
return;
}
SongBottomSheetViewModel viewModel = new ViewModelProvider(activity).get(SongBottomSheetViewModel.class);
viewModel.setSong(child);
SongBottomSheetDialog dialog = new SongBottomSheetDialog();
Bundle args = new Bundle();
args.putParcelable(Constants.TRACK_OBJECT, child);
dialog.setArguments(args);
dialog.show(activity.getSupportFragmentManager(), null);
}
};
liveData.observe(activity, observer);
}
private void openAlbum(@NonNull String id) {
MutableLiveData<AlbumID3> liveData = albumRepository.getAlbum(id);
Observer<AlbumID3> observer = new Observer<AlbumID3>() {
@Override
public void onChanged(AlbumID3 album) {
liveData.removeObserver(this);
if (album == null) {
Toast.makeText(activity, R.string.asset_link_error_album, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ALBUM_OBJECT, album);
navigateSafely(R.id.albumPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openArtist(@NonNull String id) {
MutableLiveData<ArtistID3> liveData = artistRepository.getArtist(id);
Observer<ArtistID3> observer = new Observer<ArtistID3>() {
@Override
public void onChanged(ArtistID3 artist) {
liveData.removeObserver(this);
if (artist == null) {
Toast.makeText(activity, R.string.asset_link_error_artist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ARTIST_OBJECT, artist);
navigateSafely(R.id.artistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openPlaylist(@NonNull String id) {
MutableLiveData<Playlist> liveData = playlistRepository.getPlaylist(id);
Observer<Playlist> observer = new Observer<Playlist>() {
@Override
public void onChanged(Playlist playlist) {
liveData.removeObserver(this);
if (playlist == null) {
Toast.makeText(activity, R.string.asset_link_error_playlist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.PLAYLIST_OBJECT, playlist);
navigateSafely(R.id.playlistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openGenre(@NonNull String genreName) {
String trimmed = genreName.trim();
if (trimmed.isEmpty()) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
return;
}
Genre genre = new Genre();
genre.setGenre(trimmed);
genre.setSongCount(0);
genre.setAlbumCount(0);
Bundle args = new Bundle();
args.putParcelable(Constants.GENRE_OBJECT, genre);
args.putString(Constants.MEDIA_BY_GENRE, Constants.MEDIA_BY_GENRE);
navigateSafely(R.id.songListPageFragment, args);
}
private void openYear(@NonNull String yearValue) {
try {
int year = Integer.parseInt(yearValue.trim());
Bundle args = new Bundle();
args.putInt("year_object", year);
args.putString(Constants.MEDIA_BY_YEAR, Constants.MEDIA_BY_YEAR);
navigateSafely(R.id.songListPageFragment, args);
} catch (NumberFormatException ex) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
}
}
private void navigateSafely(int destinationId, @Nullable Bundle args) {
activity.runOnUiThread(() -> {
NavController navController = activity.navController;
if (navController == null) {
return;
}
if (navController.getCurrentDestination() != null
&& navController.getCurrentDestination().getId() == destinationId) {
navController.navigate(destinationId, args, new NavOptions.Builder().setLaunchSingleTop(true).build());
} else {
navController.navigate(destinationId, args);
}
});
}
}

View file

@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import com.cappielloantonio.tempo.R;
import java.util.Objects;
import com.google.android.material.color.MaterialColors;
public final class AssetLinkUtil {
public static final String SCHEME = "tempo";
public static final String HOST_ASSET = "asset";
public static final String TYPE_SONG = "song";
public static final String TYPE_ALBUM = "album";
public static final String TYPE_ARTIST = "artist";
public static final String TYPE_PLAYLIST = "playlist";
public static final String TYPE_GENRE = "genre";
public static final String TYPE_YEAR = "year";
private AssetLinkUtil() {
}
@Nullable
public static AssetLink parse(@Nullable Intent intent) {
if (intent == null) return null;
return parse(intent.getData());
}
@Nullable
public static AssetLink parse(@Nullable Uri uri) {
if (uri == null) {
return null;
}
if (!SCHEME.equalsIgnoreCase(uri.getScheme())) {
return null;
}
String host = uri.getHost();
if (!HOST_ASSET.equalsIgnoreCase(host)) {
return null;
}
if (uri.getPathSegments().size() < 2) {
return null;
}
String type = uri.getPathSegments().get(0);
String id = uri.getPathSegments().get(1);
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id)) {
return null;
}
if (!isSupportedType(type)) {
return null;
}
return new AssetLink(type, id, uri);
}
public static boolean isSupportedType(@Nullable String type) {
if (type == null) return false;
switch (type) {
case TYPE_SONG:
case TYPE_ALBUM:
case TYPE_ARTIST:
case TYPE_PLAYLIST:
case TYPE_GENRE:
case TYPE_YEAR:
return true;
default:
return false;
}
}
@NonNull
public static Uri buildUri(@NonNull String type, @NonNull String id) {
return new Uri.Builder()
.scheme(SCHEME)
.authority(HOST_ASSET)
.appendPath(type)
.appendPath(id)
.build();
}
@Nullable
public static String buildLink(@Nullable String type, @Nullable String id) {
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || !isSupportedType(type)) {
return null;
}
return buildUri(Objects.requireNonNull(type), Objects.requireNonNull(id)).toString();
}
@Nullable
public static AssetLink buildAssetLink(@Nullable String type, @Nullable String id) {
String link = buildLink(type, id);
return parseLinkString(link);
}
@Nullable
public static AssetLink parseLinkString(@Nullable String link) {
if (TextUtils.isEmpty(link)) {
return null;
}
return parse(Uri.parse(link));
}
public static void copyToClipboard(@NonNull Context context, @NonNull AssetLink assetLink) {
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager == null) {
return;
}
ClipData clipData = ClipData.newPlainText(context.getString(R.string.asset_link_clipboard_label), assetLink.uri.toString());
clipboardManager.setPrimaryClip(clipData);
}
@StringRes
public static int getLabelRes(@NonNull String type) {
switch (type) {
case TYPE_SONG:
return R.string.asset_link_label_song;
case TYPE_ALBUM:
return R.string.asset_link_label_album;
case TYPE_ARTIST:
return R.string.asset_link_label_artist;
case TYPE_PLAYLIST:
return R.string.asset_link_label_playlist;
case TYPE_GENRE:
return R.string.asset_link_label_genre;
case TYPE_YEAR:
return R.string.asset_link_label_year;
default:
return R.string.asset_link_label_unknown;
}
}
public static void applyLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
if (textView.getTag(R.id.tag_link_original_color) == null) {
textView.setTag(R.id.tag_link_original_color, textView.getCurrentTextColor());
}
int accent = MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimary,
ContextCompat.getColor(view.getContext(), android.R.color.holo_blue_light));
textView.setTextColor(accent);
}
}
public static void clearLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
Object original = textView.getTag(R.id.tag_link_original_color);
if (original instanceof Integer) {
textView.setTextColor((Integer) original);
} else {
int defaultColor = MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnSurface,
ContextCompat.getColor(view.getContext(), android.R.color.primary_text_light));
textView.setTextColor(defaultColor);
}
}
}
public static final class AssetLink {
public final String type;
public final String id;
public final Uri uri;
AssetLink(@NonNull String type, @NonNull String id, @NonNull Uri uri) {
this.type = type;
this.id = id;
this.uri = uri;
}
}
}

View file

@ -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"
}

View file

@ -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];
}
}

View file

@ -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
)
}
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,393 @@
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.File;
import java.io.FileInputStream;
import java.io.IOException;
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() != null ? mediaUri.getScheme().toLowerCase(Locale.ROOT) : "";
HttpURLConnection connection = null;
DocumentFile sourceDocument = null;
File sourceFile = null;
long remoteLength = -1;
String mimeType = null;
DocumentFile targetFile = null;
try {
if (scheme.equals("http") || scheme.equals("https")) {
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;
}
mimeType = connection.getContentType();
remoteLength = connection.getContentLengthLong();
} else if (scheme.equals("content")) {
sourceDocument = DocumentFile.fromSingleUri(context, mediaUri);
mimeType = context.getContentResolver().getType(mediaUri);
if (sourceDocument != null) {
remoteLength = sourceDocument.length();
}
} else if (scheme.equals("file")) {
String path = mediaUri.getPath();
if (path != null) {
sourceFile = new File(path);
if (sourceFile.exists()) {
remoteLength = sourceFile.length();
}
}
String ext = MimeTypeMap.getFileExtensionFromUrl(mediaUri.toString());
if (ext != null && !ext.isEmpty()) {
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
}
} else {
notifyFailure(context, "Unsupported media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
if (mimeType == null || mimeType.isEmpty()) {
mimeType = "application/octet-stream";
}
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if ((extension == null || extension.isEmpty()) && sourceDocument != null && sourceDocument.getName() != null) {
String name = sourceDocument.getName();
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
extension = name.substring(dot + 1);
}
}
if ((extension == null || extension.isEmpty()) && sourceFile != null) {
String name = sourceFile.getName();
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
extension = name.substring(dot + 1);
}
}
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 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 = openInputStream(context, mediaUri, scheme, connection, sourceFile);
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
);
}
private static InputStream openInputStream(Context context,
Uri mediaUri,
String scheme,
HttpURLConnection connection,
File sourceFile) throws IOException {
switch (scheme) {
case "http":
case "https":
if (connection == null) {
throw new IOException("Connection not initialized");
}
return connection.getInputStream();
case "content":
InputStream contentStream = context.getContentResolver().openInputStream(mediaUri);
if (contentStream == null) {
throw new IOException("Cannot open content stream");
}
return contentStream;
case "file":
if (sourceFile == null || !sourceFile.exists()) {
throw new IOException("Missing source file");
}
return new FileInputStream(sourceFile);
default:
throw new IOException("Unsupported scheme " + scheme);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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;
@ -71,6 +74,12 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
return new MediaItem.Builder()
.setMediaId(media.getId())
@ -83,6 +92,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 +126,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 +141,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 +240,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 +263,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());
}
}

View file

@ -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,12 @@ 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"
private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
@JvmStatic
fun getServer(): String? {
@ -161,6 +169,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 +328,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 +364,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 +467,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 +600,54 @@ 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 }
}
@JvmStatic
fun getAlbumSortOrder(): String {
return App.getInstance().preferences.getString(ALBUM_SORT_ORDER, DEFAULT_ALBUM_SORT_ORDER) ?: DEFAULT_ALBUM_SORT_ORDER
}
@JvmStatic
fun setAlbumSortOrder(sortOrder: String) {
App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply()
}
}

View file

@ -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");
}
}
///
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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());
}
}

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