Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82c22ed247 | ||
|
|
48ce3a2a4f | ||
|
|
b93acc6563 | ||
|
|
9c088a7e88 | ||
|
|
de2f1067a7 | ||
|
|
1c21546461 | ||
|
|
a4121e8d49 | ||
|
|
4cc4cc7363 | ||
|
|
c5ef274916 | ||
|
|
2c53f36a18 | ||
|
|
6c637dcbcb | ||
|
|
89fa38f5a0 | ||
|
|
87f8bdc618 | ||
|
|
903fde4bdc | ||
|
|
3824dd882c | ||
|
|
602bab6414 | ||
|
|
d891e429b6 | ||
|
|
ebefd77027 | ||
|
|
57f34affd9 | ||
|
|
50b5ab38bc | ||
|
|
9f61d70fca | ||
|
|
a97a2d5b50 | ||
|
|
8d73a2cd36 | ||
|
|
ca5a0698bb | ||
|
|
04f34e03d1 | ||
|
|
233bc9987e | ||
|
|
f0e418687e | ||
|
|
e87b658447 | ||
|
|
19c985c9e4 | ||
|
|
4ab122a9d7 | ||
|
|
2335bf2095 |
62
.github/workflows/github_release.yml
vendored
62
.github/workflows/github_release.yml
vendored
@@ -35,12 +35,18 @@ jobs:
|
||||
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- name: Build APK
|
||||
- name: Build All APKs
|
||||
id: build
|
||||
run: bash ./gradlew assembleTempoRelease
|
||||
run: |
|
||||
# Build release variants
|
||||
bash ./gradlew assembleTempoRelease
|
||||
bash ./gradlew assembleNotquitemyRelease
|
||||
# Build debug variants
|
||||
bash ./gradlew assembleTempoDebug
|
||||
bash ./gradlew assembleNotquitemyDebug
|
||||
|
||||
- name: Sign APK
|
||||
id: sign_apk
|
||||
- name: Sign Tempo Release APKs
|
||||
id: sign_tempo_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/tempo/release
|
||||
@@ -51,11 +57,17 @@ jobs:
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Make artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Sign NotQuiteMy Release APKs
|
||||
id: sign_notquitemy_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
name: app-release-signed
|
||||
path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
releaseDirectory: app/build/outputs/apk/notquitemy/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -67,12 +79,40 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Upload APK
|
||||
- name: Upload Release APKs
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
asset_path: ${{steps.sign_tempo_release.outputs.signedReleaseFile}}
|
||||
asset_name: app-tempo-release.apk
|
||||
asset_content_type: application/zip
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
|
||||
- name: Upload NotQuiteMy Release APK
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
|
||||
asset_name: app-notquitemy-release.apk
|
||||
asset_content_type: application/vnd.android.package-archive
|
||||
|
||||
- name: Upload Debug APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apks
|
||||
path: |
|
||||
app/build/outputs/apk/tempo/debug/
|
||||
app/build/outputs/apk/notquitemy/debug/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Release APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-apks
|
||||
path: |
|
||||
${{steps.sign_tempo_release.outputs.signedReleaseFile}}
|
||||
${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
|
||||
retention-days: 30
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -2,6 +2,40 @@
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
|
||||
## [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
|
||||
|
||||
25
README.md
25
README.md
@@ -29,6 +29,16 @@ sha256 signing key fingerprint
|
||||
|
||||
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.
|
||||
|
||||
### 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](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
|
||||
|
||||
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
|
||||
@@ -50,12 +60,9 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
|
||||
- **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.
|
||||
|
||||
## 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">
|
||||
@@ -90,6 +97,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 a UI change, please provide a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
|
||||
|
||||
Currently there are not 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.
|
||||
|
||||
15
USAGE.md
15
USAGE.md
@@ -60,7 +60,20 @@ This app works with any service that implements the Subsonic API, including:
|
||||
**TODO**
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ android {
|
||||
minSdkVersion 24
|
||||
targetSdk 35
|
||||
|
||||
versionCode 33
|
||||
versionName '3.16.0'
|
||||
versionCode 35
|
||||
versionName '3.17.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
javaCompileOptions {
|
||||
@@ -50,10 +50,6 @@ android {
|
||||
applicationId "com.cappielloantonio.notquitemy.tempo"
|
||||
}
|
||||
|
||||
play {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.play.tempo"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -115,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'
|
||||
@@ -129,4 +125,4 @@ java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ public class CustomGlideRequest {
|
||||
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
transcodedSuffix = child.transcodedSuffix
|
||||
duration = child.duration
|
||||
bitrate = child.bitrate
|
||||
samplingRate = child.samplingRate
|
||||
bitDepth = child.bitDepth
|
||||
path = child.path
|
||||
isVideo = child.isVideo
|
||||
userRating = child.userRating
|
||||
|
||||
@@ -80,6 +80,33 @@ public class PlaylistRepository {
|
||||
return listLivePlaylistSongs;
|
||||
}
|
||||
|
||||
public MutableLiveData<Playlist> getPlaylist(String id) {
|
||||
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.getPlaylist(id)
|
||||
.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().getPlaylist() != null) {
|
||||
playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist());
|
||||
} else {
|
||||
playlistLiveData.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
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();
|
||||
|
||||
@@ -37,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;
|
||||
@@ -60,6 +62,8 @@ 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;
|
||||
@@ -76,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);
|
||||
@@ -311,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() {
|
||||
@@ -443,6 +466,7 @@ public class MainActivity extends BaseActivity {
|
||||
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
|
||||
pendingDownloadPlaybackIntent = new Intent(intent);
|
||||
}
|
||||
handleAssetLinkIntent(intent);
|
||||
}
|
||||
|
||||
private void consumePendingPlaybackIntent() {
|
||||
@@ -452,6 +476,35 @@ public class MainActivity extends BaseActivity {
|
||||
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)) {
|
||||
@@ -500,4 +553,4 @@ public class MainActivity extends BaseActivity {
|
||||
|
||||
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
|
||||
R.string.song_subtitle_formatter,
|
||||
song.getArtist(),
|
||||
MusicUtil.getReadableDurationString(song.getDuration(), false),
|
||||
""
|
||||
MusicUtil.getReadableAudioQualityString(song)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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;
|
||||
@@ -177,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());
|
||||
@@ -347,4 +375,23 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -13,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;
|
||||
@@ -41,12 +42,14 @@ 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;
|
||||
@@ -76,6 +79,10 @@ public class PlayerControllerFragment extends Fragment {
|
||||
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;
|
||||
@@ -139,6 +146,10 @@ public class PlayerControllerFragment extends Fragment {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -219,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) {
|
||||
@@ -259,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);
|
||||
|
||||
@@ -548,4 +665,4 @@ public class PlayerControllerFragment extends Fragment {
|
||||
isServiceBound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
|
||||
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||
ExternalAudioReader.refreshCache();
|
||||
Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show();
|
||||
Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show();
|
||||
checkDownloadDirectory();
|
||||
}
|
||||
}
|
||||
@@ -238,15 +238,15 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
if (storage != null) storage.setVisible(false);
|
||||
directory.setVisible(true);
|
||||
directory.setIcon(R.drawable.ic_close);
|
||||
directory.setTitle("Clear download folder");
|
||||
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("Set download folder");
|
||||
directory.setSummary("Choose a folder for downloaded music files");
|
||||
directory.setTitle(R.string.settings_set_download_folder);
|
||||
directory.setSummary(R.string.settings_choose_download_folder);
|
||||
} else {
|
||||
directory.setVisible(false);
|
||||
}
|
||||
@@ -325,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();
|
||||
}
|
||||
});
|
||||
@@ -430,7 +430,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||
Preferences.setDownloadDirectoryUri(null);
|
||||
Preferences.setDownloadStoragePreference(0);
|
||||
ExternalAudioReader.refreshCache();
|
||||
Toast.makeText(requireContext(), "Download folder cleared.", Toast.LENGTH_SHORT).show();
|
||||
Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
|
||||
checkStorage();
|
||||
checkDownloadDirectory();
|
||||
} else {
|
||||
@@ -492,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ 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;
|
||||
@@ -39,6 +40,8 @@ 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;
|
||||
@@ -56,6 +59,13 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
|
||||
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;
|
||||
|
||||
@@ -109,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 -> {
|
||||
@@ -282,6 +297,95 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
|
||||
}
|
||||
@@ -293,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||
private void refreshShares() {
|
||||
homeViewModel.refreshShares(requireActivity());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ 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;
|
||||
@@ -102,35 +105,76 @@ public class ExternalAudioWriter {
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
return;
|
||||
}
|
||||
String scheme = mediaUri.getScheme();
|
||||
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
|
||||
notifyFailure(context, "Unsupported media URI.");
|
||||
ExternalDownloadMetadataStore.remove(metadataKey);
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
String mimeType = connection.getContentType();
|
||||
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()) {
|
||||
@@ -146,7 +190,6 @@ public class ExternalAudioWriter {
|
||||
String fileName = sanitized + "." + extension;
|
||||
|
||||
DocumentFile existingFile = findFile(directory, fileName);
|
||||
long remoteLength = connection.getContentLengthLong();
|
||||
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
|
||||
if (existingFile != null && existingFile.exists()) {
|
||||
long localLength = existingFile.length();
|
||||
@@ -175,7 +218,7 @@ public class ExternalAudioWriter {
|
||||
}
|
||||
|
||||
Uri targetUri = targetFile.getUri();
|
||||
try (InputStream in = connection.getInputStream();
|
||||
try (InputStream in = openInputStream(context, mediaUri, scheme, connection, sourceFile);
|
||||
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
|
||||
if (out == null) {
|
||||
notifyFailure(context, "Cannot open output stream.");
|
||||
@@ -319,4 +362,32 @@ public class ExternalAudioWriter {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,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())
|
||||
@@ -120,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(
|
||||
@@ -130,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()
|
||||
)
|
||||
|
||||
@@ -5,17 +5,20 @@ import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
|
||||
import android.app.TaskStackBuilder;
|
||||
import android.app.PendingIntent;
|
||||
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class WidgetProvider extends AppWidgetProvider {
|
||||
private static final String TAG = "TempoWidget";
|
||||
public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE";
|
||||
@@ -28,7 +31,7 @@ public class WidgetProvider extends AppWidgetProvider {
|
||||
public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
|
||||
for (int id : ids) {
|
||||
RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
|
||||
attachIntents(ctx, rv, id);
|
||||
attachIntents(ctx, rv, id, null, null, null);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -50,16 +53,23 @@ public class WidgetProvider extends AppWidgetProvider {
|
||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||
RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
|
||||
attachIntents(context, rv, appWidgetId);
|
||||
attachIntents(context, rv, appWidgetId, null, null, null);
|
||||
appWidgetManager.updateAppWidget(appWidgetId, rv);
|
||||
WidgetUpdateManager.refreshFromController(context);
|
||||
}
|
||||
|
||||
public static void attachIntents(Context ctx, RemoteViews rv) {
|
||||
attachIntents(ctx, rv, 0);
|
||||
attachIntents(ctx, rv, 0, null, null, null);
|
||||
}
|
||||
|
||||
public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) {
|
||||
attachIntents(ctx, rv, requestCodeBase, null, null, null);
|
||||
}
|
||||
|
||||
public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase,
|
||||
String songLink,
|
||||
String albumLink,
|
||||
String artistLink) {
|
||||
PendingIntent playPause = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
requestCodeBase + 0,
|
||||
@@ -97,9 +107,31 @@ public class WidgetProvider extends AppWidgetProvider {
|
||||
rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
|
||||
rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
|
||||
|
||||
PendingIntent launch = TaskStackBuilder.create(ctx)
|
||||
.addNextIntentWithParentStack(new Intent(ctx, MainActivity.class))
|
||||
.getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
PendingIntent launch = buildMainActivityPendingIntent(ctx, requestCodeBase + 10, null);
|
||||
rv.setOnClickPendingIntent(R.id.root, launch);
|
||||
|
||||
PendingIntent songPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 20, songLink);
|
||||
PendingIntent artistPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 21, artistLink);
|
||||
PendingIntent albumPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 22, albumLink);
|
||||
|
||||
PendingIntent fallback = launch;
|
||||
rv.setOnClickPendingIntent(R.id.album_art, songPending != null ? songPending : fallback);
|
||||
rv.setOnClickPendingIntent(R.id.title, songPending != null ? songPending : fallback);
|
||||
rv.setOnClickPendingIntent(R.id.subtitle,
|
||||
artistPending != null ? artistPending : (songPending != null ? songPending : fallback));
|
||||
rv.setOnClickPendingIntent(R.id.album, albumPending != null ? albumPending : fallback);
|
||||
}
|
||||
|
||||
private static PendingIntent buildMainActivityPendingIntent(Context ctx, int requestCode, @Nullable String link) {
|
||||
Intent intent;
|
||||
if (!TextUtils.isEmpty(link)) {
|
||||
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity.class);
|
||||
} else {
|
||||
intent = new Intent(ctx, MainActivity.class);
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
TaskStackBuilder stackBuilder = TaskStackBuilder.create(ctx);
|
||||
stackBuilder.addNextIntentWithParentStack(intent);
|
||||
return stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import android.appwidget.AppWidgetManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.text.TextUtils;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
@@ -17,6 +18,7 @@ import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
@@ -34,7 +36,10 @@ public final class WidgetUpdateManager {
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode,
|
||||
long positionMs,
|
||||
long durationMs) {
|
||||
long durationMs,
|
||||
String songLink,
|
||||
String albumLink,
|
||||
String artistLink) {
|
||||
if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing);
|
||||
if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
|
||||
if (TextUtils.isEmpty(album)) album = "";
|
||||
@@ -46,7 +51,7 @@ public final class WidgetUpdateManager {
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
|
||||
timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id, songLink, albumLink, artistLink);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +61,7 @@ public final class WidgetUpdateManager {
|
||||
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = chooseBuild(ctx, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id);
|
||||
WidgetProvider.attachIntents(ctx, rv, id, null, null, null);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -70,7 +75,10 @@ public final class WidgetUpdateManager {
|
||||
boolean shuffleEnabled,
|
||||
int repeatMode,
|
||||
long positionMs,
|
||||
long durationMs) {
|
||||
long durationMs,
|
||||
String songLink,
|
||||
String albumLink,
|
||||
String artistLink) {
|
||||
final Context appCtx = ctx.getApplicationContext();
|
||||
final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title;
|
||||
final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist;
|
||||
@@ -79,6 +87,9 @@ public final class WidgetUpdateManager {
|
||||
final boolean sh = shuffleEnabled;
|
||||
final int rep = repeatMode;
|
||||
final TimingInfo timing = createTimingInfo(positionMs, durationMs);
|
||||
final String songLinkFinal = songLink;
|
||||
final String albumLinkFinal = albumLink;
|
||||
final String artistLinkFinal = artistLink;
|
||||
|
||||
if (!TextUtils.isEmpty(coverArtId)) {
|
||||
CustomGlideRequest.loadAlbumArtBitmap(
|
||||
@@ -93,7 +104,7 @@ public final class WidgetUpdateManager {
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -105,7 +116,7 @@ public final class WidgetUpdateManager {
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -117,7 +128,7 @@ public final class WidgetUpdateManager {
|
||||
for (int id : ids) {
|
||||
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
|
||||
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id);
|
||||
WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
|
||||
mgr.updateAppWidget(id, rv);
|
||||
}
|
||||
}
|
||||
@@ -133,6 +144,7 @@ public final class WidgetUpdateManager {
|
||||
MediaController c = future.get();
|
||||
androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
|
||||
String title = null, artist = null, album = null, coverId = null;
|
||||
String songLink = null, albumLink = null, artistLink = null;
|
||||
if (mi != null && mi.mediaMetadata != null) {
|
||||
if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
|
||||
if (mi.mediaMetadata.artist != null)
|
||||
@@ -140,10 +152,26 @@ public final class WidgetUpdateManager {
|
||||
if (mi.mediaMetadata.albumTitle != null)
|
||||
album = mi.mediaMetadata.albumTitle.toString();
|
||||
if (mi.mediaMetadata.extras != null) {
|
||||
Bundle extras = mi.mediaMetadata.extras;
|
||||
if (title == null) title = mi.mediaMetadata.extras.getString("title");
|
||||
if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
|
||||
if (album == null) album = mi.mediaMetadata.extras.getString("album");
|
||||
coverId = mi.mediaMetadata.extras.getString("coverArtId");
|
||||
coverId = extras.getString("coverArtId");
|
||||
|
||||
songLink = extras.getString("assetLinkSong");
|
||||
if (songLink == null) {
|
||||
songLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras.getString("id"));
|
||||
}
|
||||
|
||||
albumLink = extras.getString("assetLinkAlbum");
|
||||
if (albumLink == null) {
|
||||
albumLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras.getString("albumId"));
|
||||
}
|
||||
|
||||
artistLink = extras.getString("assetLinkArtist");
|
||||
if (artistLink == null) {
|
||||
artistLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras.getString("artistId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
long position = c.getCurrentPosition();
|
||||
@@ -159,7 +187,10 @@ public final class WidgetUpdateManager {
|
||||
c.getShuffleModeEnabled(),
|
||||
c.getRepeatMode(),
|
||||
position,
|
||||
duration);
|
||||
duration,
|
||||
songLink,
|
||||
albumLink,
|
||||
artistLink);
|
||||
c.release();
|
||||
} catch (ExecutionException | InterruptedException ignored) {
|
||||
}
|
||||
|
||||
10
app/src/main/res/drawable/ic_link.xml
Normal file
10
app/src/main/res/drawable/ic_link.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M3.9,12c0,1.71 1.39,3.1 3.1,3.1h3v1.8h-3c-2.7,0 -4.9,-2.2 -4.9,-4.9s2.2,-4.9 4.9,-4.9h3v1.8h-3c-1.71,0 -3.1,1.39 -3.1,3.1zM7,13h10v-2H7v2zM17,6.9h-3v-1.8h3c2.7,0 4.9,2.2 4.9,4.9s-2.2,4.9 -4.9,4.9h-3v-1.8h3c1.71,0 3.1,-1.39 3.1,-3.1s-1.39,-3.1 -3.1,-3.1z" />
|
||||
</vector>
|
||||
@@ -68,6 +68,14 @@
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<include
|
||||
android:id="@+id/song_asset_link_row"
|
||||
layout="@layout/view_asset_link_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/option_linear_layout"
|
||||
android:layout_width="match_parent"
|
||||
@@ -209,4 +217,4 @@
|
||||
android:text="@string/song_bottom_sheet_share"
|
||||
android:visibility="gone"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -57,6 +57,17 @@
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<include
|
||||
android:id="@+id/player_asset_link_row"
|
||||
layout="@layout/view_asset_link_row"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/player_media_quality_sector" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/player_media_cover_view_pager"
|
||||
android:layout_width="0dp"
|
||||
@@ -66,7 +77,7 @@
|
||||
app:layout_constraintBottom_toTopOf="@id/guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/player_media_quality_sector" />
|
||||
app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/guideline"
|
||||
@@ -400,4 +411,4 @@
|
||||
app:srcCompat="@drawable/ic_eq" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
55
app/src/main/res/layout/view_asset_link_row.xml
Normal file
55
app/src/main/res/layout/view_asset_link_row.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.chip.ChipGroup xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/asset_link_chip_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
app:singleLine="true"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="false">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/asset_link_song_chip"
|
||||
style="@style/Widget.Material3.Chip.Assist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="false"
|
||||
android:clickable="true"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text=""
|
||||
app:chipIcon="@drawable/ic_link"
|
||||
app:chipIconTint="?attr/colorOnSurfaceVariant"
|
||||
app:rippleColor="@color/ripple_material_light" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/asset_link_album_chip"
|
||||
style="@style/Widget.Material3.Chip.Assist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="false"
|
||||
android:clickable="true"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text=""
|
||||
app:chipIcon="@drawable/ic_link"
|
||||
app:chipIconTint="?attr/colorOnSurfaceVariant"
|
||||
app:rippleColor="@color/ripple_material_light" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/asset_link_artist_chip"
|
||||
style="@style/Widget.Material3.Chip.Assist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="false"
|
||||
android:clickable="true"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text=""
|
||||
app:chipIcon="@drawable/ic_link"
|
||||
app:chipIconTint="?attr/colorOnSurfaceVariant"
|
||||
app:rippleColor="@color/ripple_material_light" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
@@ -69,6 +69,7 @@
|
||||
<string name="download_directory_dialog_positive_button">Descargar</string>
|
||||
<string name="download_directory_dialog_summary">Se descargarán todas las pistas de esta carpeta. Las pistas en las subcarpetas no se descargarán.</string>
|
||||
<string name="download_directory_dialog_title">Descargar las pistas</string>
|
||||
<string name="download_directory_set">Indicar ubicación de descarga</string>
|
||||
<string name="download_info_empty_subtitle">Una vez que descargues una pista, la encontrarás aquí</string>
|
||||
<string name="download_info_empty_title">No hay descargas</string>
|
||||
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s elementos</string>
|
||||
@@ -79,7 +80,10 @@
|
||||
<string name="download_storage_dialog_title">Selecciona una opción de almacenamiento</string>
|
||||
<string name="download_storage_external_dialog_positive_button">Externo</string>
|
||||
<string name="download_storage_internal_dialog_negative_button">Interno</string>
|
||||
<string name="download_storage_directory_dialog_neutral_button">Directorio</string>
|
||||
<string name="download_title_section">Descargas</string>
|
||||
<string name="download_refresh_no_changes">No se han encontrado descargas que falten</string>
|
||||
<string name="download_refresh_button_content_description">Actualizar descargas</string>
|
||||
<string name="downloaded_bottom_sheet_add_to_queue">Añadir a la cola</string>
|
||||
<string name="downloaded_bottom_sheet_play_next">Reproducir siguiente</string>
|
||||
<string name="downloaded_bottom_sheet_remove">Eliminar</string>
|
||||
@@ -88,6 +92,8 @@
|
||||
<string name="error_required">Obligatorio</string>
|
||||
<string name="error_server_prefix">Se necesita un prefijo http o https</string>
|
||||
<string name="exo_download_notification_channel_name">Descargas</string>
|
||||
<string name="exo_controls_heart_on_description">Añadir a favoritos</string>
|
||||
<string name="cast_expanded_controller_loading">Cargando…</string>
|
||||
<string name="filter_info_selection">Selecciona dos o más filtros</string>
|
||||
<string name="filter_title">Filtrar</string>
|
||||
<string name="filter_artist">Filtrar artistas</string>
|
||||
@@ -118,6 +124,7 @@
|
||||
<string name="home_sync_starred_subtitle">Descargar estas pistas usará una gran cantidad de datos</string>
|
||||
<string name="home_sync_starred_title">Parece que hay algunas pistas destacadas para sincronizar</string>
|
||||
<string name="home_sync_starred_albums_subtitle">Los álbumes marcados como favoritos estarán disponibles en el modo sin conexión.</string>
|
||||
<string name="home_sync_starred_artists_subtitle">Has destacado artistas con música que no has descargado</string>
|
||||
<string name="home_title_best_of">Lo mejor de</string>
|
||||
<string name="home_title_discovery">Descubrir</string>
|
||||
<string name="home_title_discovery_shuffle_all_button">Todo en aleatorio</string>
|
||||
@@ -170,6 +177,8 @@
|
||||
<string name="menu_filter_download">Descargado</string>
|
||||
<string name="menu_group_by_album">Álbum</string>
|
||||
<string name="menu_group_by_artist">Artista</string>
|
||||
<string name="settings_scan_result">Escaneo: hay %1$d pistas</string>
|
||||
<string name="settings_support_title">Soporte al usuario</string>
|
||||
<string name="settings_image_size">Resolución de la imagen</string>
|
||||
<string name="settings_language">Idioma</string>
|
||||
<string name="settings_system_language">Idioma del sistema</string>
|
||||
@@ -201,6 +210,7 @@
|
||||
<string name="player_playback_speed">%1$.2fx</string>
|
||||
<string name="player_queue_clean_all_button">Limpiar la cola de reproducción</string>
|
||||
<string name="player_queue_save_queue_success">Cola de reproducción guardada</string>
|
||||
<string name="player_lyrics_download_failure">La letra no se puede descargar</string>
|
||||
<string name="player_server_priority">Prioridad del servidor</string>
|
||||
<string name="player_unknown_format">Formato desconocido</string>
|
||||
<string name="player_transcoding">Transcodificando</string>
|
||||
@@ -212,6 +222,7 @@
|
||||
<string name="playlist_chooser_dialog_neutral_button">Crear</string>
|
||||
<string name="playlist_chooser_dialog_title">Añadir a una lista de reproducción</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_failure">Error al añadir a la lista</string>
|
||||
<string name="playlist_chooser_dialog_toast_all_skipped">Todas las pistas se han descartado porque están repetidas</string>
|
||||
<string name="playlist_counted_tracks">%1$d pistas • %2$s</string>
|
||||
<string name="playlist_duration">Duración • %1$s</string>
|
||||
<string name="playlist_editor_dialog_action_delete_toast">Pulsación larga para eliminar</string>
|
||||
@@ -257,6 +268,7 @@
|
||||
<string name="search_hint">Buscar pista, artistas o álbumes</string>
|
||||
<string name="search_info_minimum_characters">Introduzca al menos tres caracteres</string>
|
||||
<string name="search_title_album">Álbumes</string>
|
||||
<string name="settings_set_download_folder">Establecer la carpeta de descargas</string>
|
||||
<string name="settings_system_equalizer_summary">Ajustes de audio</string>
|
||||
<string name="settings_system_equalizer_title">Ecualizador del sistema</string>
|
||||
<string name="search_title_artist">Artistas</string>
|
||||
@@ -280,6 +292,7 @@
|
||||
<string name="settings_about_summary">Tempo es un cliente de música Subsonic ligero y de código abierto, diseñado nativamente para Android.</string>
|
||||
<string name="settings_about_title">Acerca de</string>
|
||||
<string name="settings_always_on_display">Pantalla siempre activa</string>
|
||||
<string name="settings_allow_playlist_duplicates_summary">Si está habilitada, no se comprobará si hay pistas repetidas cuando se añadan a la lista.</string>
|
||||
<string name="settings_audio_transcode_download_format">Formato de transcodificación</string>
|
||||
<string name="settings_audio_transcode_download_priority_summary">Si está habilitada, Tempo no descargará la pista con las opciones de transcodificación que aparecen a continuación.</string>
|
||||
<string name="settings_audio_transcode_download_priority_title">Dar prioridad a las opciones del servidor usadas para el streaming en las descargas</string>
|
||||
@@ -295,6 +308,8 @@
|
||||
<string name="settings_audio_transcode_priority_toast">Prioridad a la hora de transcodificar una pista</string>
|
||||
<string name="settings_buffering_strategy">Estrategia de buffer</string>
|
||||
<string name="settings_buffering_strategy_summary">Para que los cambios surtan efecto, debes reinciar la app.</string>
|
||||
<string name="settings_choose_download_folder">Elige una carpeta para descargar los archivos de música</string>
|
||||
<string name="settings_clear_download_folder">Limpiar la carpeta de descargas</string>
|
||||
<string name="settings_continuous_play_summary">Permite que la música siga reproduciéndose una vez que la lista de reproducción ha terminado, reproduciendo pistas similares</string>
|
||||
<string name="settings_continuous_play_title">Reproducción continua</string>
|
||||
<string name="settings_covers_cache">Tamaño de la caché de portadas de álbumes</string>
|
||||
@@ -316,7 +331,9 @@
|
||||
<string name="settings_song_rating_summary">Si está habilitada, muestra la valoración de la pista como barra de 5 estrellas en la página del control de reproducción.\n\n*Requiere reiniciar la aplicación</string>
|
||||
<string name="settings_item_rating">Mostrar valoración de los elementos</string>
|
||||
<string name="settings_queue_syncing_title">Sincronizar cola de reproducción para este usuario</string>
|
||||
<string name="settings_show_mini_shuffle_button_summary">Si está habilitada, muestra el botón de reproducción aleatoria y oculta el botón de «Favoritos» en el minirreproductor</string>
|
||||
<string name="settings_radio">Mostrar emisoras de radio</string>
|
||||
<string name="settings_auto_download_lyrics_summary">Descargar las letras automáticamente cuando estén disponibles para que se puedan mostrar cuando no hay conexión.</string>
|
||||
<string name="settings_replay_gain">Configurar el modo de ganancia de reproducción</string>
|
||||
<string name="settings_rounded_corner">Esquinas redondeadas</string>
|
||||
<string name="settings_rounded_corner_size">Tamaño de las esquinas</string>
|
||||
@@ -368,6 +385,7 @@
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_title_data">Datos</string>
|
||||
<string name="settings_title_general">General</string>
|
||||
<string name="settings_title_playlist">Lista de reproducción</string>
|
||||
<string name="settings_title_rating">Valoraciones</string>
|
||||
<string name="settings_title_replay_gain">Ganancia de reproducción</string>
|
||||
<string name="settings_title_scrobble">Rastreo de música (scrobble)</string>
|
||||
@@ -433,7 +451,9 @@
|
||||
<string name="playlist_chooser_dialog_toast_add_success">Se ha añadido a la lista</string>
|
||||
<string name="settings_song_rating">Mostrar valoración de las pistas</string>
|
||||
<string name="home_sync_starred_albums_title">Sincronizar álbumes favoritos</string>
|
||||
<string name="settings_sync_starred_artists_for_offline_use_title">Sincronizar artistas destacados para uso sin conexión</string>
|
||||
<string name="settings_sync_starred_albums_for_offline_use_summary">Si está habilitada, los álbumes favoritos se descargarán para uso sin conexión.</string>
|
||||
<string name="starred_artist_sync_dialog_title">Sincronizar artistas destacados</string>
|
||||
<string name="starred_album_sync_dialog_summary">Descargar los álbumes favoritos puede consumir una gran cantidad de datos.</string>
|
||||
<string name="equalizer_fragment_title">Ecualizador</string>
|
||||
<string name="equalizer_reset">Restablecer</string>
|
||||
@@ -441,4 +461,29 @@
|
||||
<string name="equalizer_not_supported">No disponible en este dispositivo</string>
|
||||
<string name="settings_app_equalizer">Ecualizador</string>
|
||||
<string name="settings_app_equalizer_summary">Abrir el ecualizador integrado</string>
|
||||
<string name="settings_download_folder_cleared">Se ha limpiado la carpeta de descargas.</string>
|
||||
<string name="settings_download_folder_set">Se ha establecido la carpeta de descargas</string>
|
||||
<string name="widget_label">Widget de Tempo</string>
|
||||
<string name="widget_not_playing">En pausa</string>
|
||||
<string name="widget_placeholder_subtitle">Abrir Tempo</string>
|
||||
<string name="widget_time_duration_placeholder">0:00</string>
|
||||
<string name="widget_content_desc_album_art">Portada del álbum</string>
|
||||
<string name="widget_content_desc_play_pause">Reproducir o pausar</string>
|
||||
<string name="widget_content_desc_next">Siguiente pista</string>
|
||||
<string name="widget_content_desc_repeat">Cambiar modo de repetición</string>
|
||||
<string name="widget_content_desc_shuffle">Activar/desactivar aleatorio</string>
|
||||
<string name="widget_content_desc_prev">Pista anterior</string>
|
||||
<string name="download_refresh_no_directory">Establece una carpeta de descarga para actualizar tus descargas</string>
|
||||
<string name="home_sync_starred_artists_title">Sincronizar artistas destacados</string>
|
||||
<string name="player_lyrics_download_content_description">Descargar letras para uso sin conexión</string>
|
||||
<string name="player_lyrics_downloaded_content_description">Letras descargadas para uso sin conexión</string>
|
||||
<string name="player_lyrics_download_success">Letra guardada para uso sin conexión</string>
|
||||
<string name="settings_allow_playlist_duplicates">Permitir añadir pistas repetidas a la lista</string>
|
||||
<string name="settings_support_summary">Participa en las discusiones y el soporte de la comunidad</string>
|
||||
<string name="settings_show_mini_shuffle_button">Mostrar el botón «Aleatorio»</string>
|
||||
<string name="settings_auto_download_lyrics">Descargar automáticamente las letras</string>
|
||||
<string name="starred_artist_sync_dialog_summary">Descargar los artistas destacados podría consumir una gran cantidad de datos.</string>
|
||||
<string name="settings_sync_starred_artists_for_offline_use_summary">Si está habilitada, los artistas destacados se descargarán para uso sin conexión.</string>
|
||||
<string name="widget_time_elapsed_placeholder">0:00</string>
|
||||
<string name="exo_controls_heart_off_description">Eliminar de favoritos</string>
|
||||
</resources>
|
||||
@@ -88,6 +88,9 @@
|
||||
<string name="error_required">Wymagane</string>
|
||||
<string name="error_server_prefix">wymagany jest prefiks http lub https</string>
|
||||
<string name="exo_download_notification_channel_name">Pobieranie</string>
|
||||
<string name="exo_controls_heart_off_description">Wyłącz serce</string>
|
||||
<string name="exo_controls_heart_on_description">Włącz serce</string>
|
||||
<string name="cast_expanded_controller_loading">Ładowanie…</string>
|
||||
<string name="filter_info_selection">Wybierz dwa lub więcej filtrów</string>
|
||||
<string name="filter_title">Filtry</string>
|
||||
<string name="filter_artist">Filtruj wykonawców</string>
|
||||
@@ -213,8 +216,9 @@
|
||||
<string name="playlist_chooser_dialog_negative_button">Anuluj</string>
|
||||
<string name="playlist_chooser_dialog_neutral_button">Utwórz</string>
|
||||
<string name="playlist_chooser_dialog_title">Dodaj do playlisty</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_success">Dodano piosenkę do playlisty</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_failure">Nie udało się dodać piosenki do playlisty</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_success">Dodano piosenki do playlisty</string>
|
||||
<string name="playlist_chooser_dialog_toast_add_failure">Nie udało się dodać piosenek do playlisty</string>
|
||||
<string name="playlist_chooser_dialog_toast_all_skipped">Pominięto wszystkie piosenki jako duplikaty</string>
|
||||
<string name="playlist_counted_tracks">%1$d utworów • %2$s</string>
|
||||
<string name="playlist_duration">Długość • %1$s</string>
|
||||
<string name="playlist_editor_dialog_action_delete_toast">Przytrzymaj aby usunąć</string>
|
||||
@@ -281,6 +285,8 @@
|
||||
<string name="settings_about_summary">Tempo jest otwarto-źródłowym i lekkim klientem muzycznym dla Subsonic, stworzonym i zbudowanym natywnie dla Androida.</string>
|
||||
<string name="settings_about_title">O aplikacji</string>
|
||||
<string name="settings_always_on_display">Always on display</string>
|
||||
<string name="settings_allow_playlist_duplicates">Zezwalaj na dodawania duplikatów do playlist</string>
|
||||
<string name="settings_allow_playlist_duplicates_summary">Jeżeli włączone, duplikaty nie będą sprawdzane podczas dodawania do playlisty.</string>
|
||||
<string name="settings_audio_transcode_download_format">Format transkodowania</string>
|
||||
<string name="settings_audio_transcode_download_priority_summary">Jeżeli włączone, Tempo nie będzię wymuszał pobierania utworu z ustawieniami transkodowania wybranymi poniżej.</string>
|
||||
<string name="settings_audio_transcode_download_priority_title">Priorytetyzuj ustawienia serwera używanego do strumieniowania w pobieraniach</string>
|
||||
@@ -296,6 +302,8 @@
|
||||
<string name="settings_audio_transcode_priority_toast">Priorytet przy transkodowaniu utworu danego serwerowi</string>
|
||||
<string name="settings_buffering_strategy">Strategia buforowania</string>
|
||||
<string name="settings_buffering_strategy_summary">Aby zmiany przyniosły efekt, musisz ręcznie zrestartować aplikację.</string>
|
||||
<string name="settings_choose_download_folder">Wybierz folder dla pobranych plików muzycznych</string>
|
||||
<string name="settings_clear_download_folder">Wyczyść folder pobierania</string>
|
||||
<string name="settings_continuous_play_summary">Pozwala muzyce odtwarzać się dalej po końcu playlisty, odtwarza podobne piosenki</string>
|
||||
<string name="settings_continuous_play_title">Odtwarzanie bez przerwy</string>
|
||||
<string name="settings_covers_cache">Rozmiar cache dla okładek</string>
|
||||
@@ -304,6 +312,9 @@
|
||||
<string name="settings_delete_download_storage_summary">Zatwierdzenie nieodwracalnie usunie wszystkie zapisane elementy</string>
|
||||
<string name="settings_delete_download_storage_title">Usuń zapisane elementy</string>
|
||||
<string name="settings_download_storage_title">Pamięć do pobierania</string>
|
||||
<string name="settings_download_folder_cleared">Utworzono folder pobierania.</string>
|
||||
<string name="settings_download_folder_set">Wybrano folder pobierania</string>
|
||||
<string name="settings_set_download_folder">Ustaw folder pobierania</string>
|
||||
<string name="settings_system_equalizer_summary">Zmień ustawienia audio</string>
|
||||
<string name="settings_system_equalizer_title">Korektor systemowy</string>
|
||||
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
|
||||
@@ -312,6 +323,7 @@
|
||||
<string name="settings_support_discussion_link">https://github.com/eddyizm/tempo/discussions</string>
|
||||
<string name="settings_support_summary">Dołącz do dyskusji i wsparcia społeczności</string>
|
||||
<string name="settings_support_title">Wsparcie użytkowników</string>
|
||||
<string name="settings_scan_result">Skanowanie: naliczono %1$d utworów</string>
|
||||
<string name="settings_image_size">Rozdzielczość obrazów</string>
|
||||
<string name="settings_language">Język</string>
|
||||
<string name="settings_logout_title">Wyloguj</string>
|
||||
@@ -332,6 +344,8 @@
|
||||
<string name="settings_queue_syncing_countdown">Timer synchronizacji</string>
|
||||
<string name="settings_queue_syncing_summary">Jeżeli włączone, użytkownik będzie miał możliwość zapisania kolejki i będzie miał możliwość załadowania jej stanu przy otwarciu aplikacji.</string>
|
||||
<string name="settings_queue_syncing_title">Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone]</string>
|
||||
<string name="settings_show_mini_shuffle_button">Pokaż przycisk odtwarzania losowego</string>
|
||||
<string name="settings_show_mini_shuffle_button_summary">Jeżeli włączone, pokazuje przycisk losowego odtwarzania, i usuwa przycisk serca w mini odtwarzaczu</string>
|
||||
<string name="settings_radio">Pokaż radio</string>
|
||||
<string name="settings_radio_summary">Jeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.</string>
|
||||
<string name="settings_auto_download_lyrics">Automatyczne pobieranie tesktów</string>
|
||||
@@ -366,6 +380,7 @@
|
||||
<string name="settings_theme">Motyw</string>
|
||||
<string name="settings_title_data">Dane</string>
|
||||
<string name="settings_title_general">Ogólne</string>
|
||||
<string name="settings_title_playlist">Playlisty</string>
|
||||
<string name="settings_title_rating">Oceny</string>
|
||||
<string name="settings_title_replay_gain">Wzmocnienie głośności przy ponownym odtwarzaniu</string>
|
||||
<string name="settings_title_scrobble">Scrobble</string>
|
||||
@@ -454,6 +469,17 @@
|
||||
<string name="undraw_page">unDraw</string>
|
||||
<string name="undraw_thanks">Specjalne podziękowania dla unDraw bez którego ilustracji nie mogliśmy uczynić tej aplikacji jeszcze piękniejszą.</string>
|
||||
<string name="undraw_url">https://undraw.co/</string>
|
||||
<string name="widget_label">Widget Tempo</string>
|
||||
<string name="widget_not_playing">Nie odtwarza</string>
|
||||
<string name="widget_placeholder_subtitle">Otwórz Tempo</string>
|
||||
<string name="widget_time_elapsed_placeholder">0:00</string>
|
||||
<string name="widget_time_duration_placeholder">0:00</string>
|
||||
<string name="widget_content_desc_album_art">Okładka albumu</string>
|
||||
<string name="widget_content_desc_play_pause">Play lub pauza</string>
|
||||
<string name="widget_content_desc_next">Następny utwór</string>
|
||||
<string name="widget_content_desc_prev">Poprzedni utwór</string>
|
||||
<string name="widget_content_desc_shuffle">Przełącznik odtwarzania losowego</string>
|
||||
<string name="widget_content_desc_repeat">Zmień tryb powtarzania</string>
|
||||
<plurals name="home_sync_starred_albums_count">
|
||||
<item quantity="one">%d album do zsynchronizowania </item>
|
||||
<item quantity="other">%d albumów do zsynchrpnizowania</item>
|
||||
|
||||
3
app/src/main/res/values/ids.xml
Normal file
3
app/src/main/res/values/ids.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<item name="tag_link_original_color" type="id" />
|
||||
</resources>
|
||||
@@ -311,6 +311,8 @@
|
||||
<string name="settings_audio_transcode_priority_toast">Priority on transcoding of track given to server</string>
|
||||
<string name="settings_buffering_strategy">Buffering strategy</string>
|
||||
<string name="settings_buffering_strategy_summary">For the change to take effect you must manually restart the app.</string>
|
||||
<string name="settings_choose_download_folder">Choose a folder for downloaded music files</string>
|
||||
<string name="settings_clear_download_folder">Clear download folder</string>
|
||||
<string name="settings_continuous_play_summary">Allows music to keep playing after a playlist has ended, playing similar songs</string>
|
||||
<string name="settings_continuous_play_title">Continuous play</string>
|
||||
<string name="settings_covers_cache">Size of artwork cache</string>
|
||||
@@ -319,6 +321,9 @@
|
||||
<string name="settings_delete_download_storage_summary">Proceeding will result in the irreversible deletion of all saved items.</string>
|
||||
<string name="settings_delete_download_storage_title">Delete saved items</string>
|
||||
<string name="settings_download_storage_title">Download storage</string>
|
||||
<string name="settings_download_folder_cleared">Download folder cleared.</string>
|
||||
<string name="settings_download_folder_set">Download folder set</string>
|
||||
<string name="settings_set_download_folder">Set download folder</string>
|
||||
<string name="settings_system_equalizer_summary">Adjust audio settings</string>
|
||||
<string name="settings_system_equalizer_title">System equalizer</string>
|
||||
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
|
||||
@@ -327,6 +332,7 @@
|
||||
<string name="settings_support_discussion_link">https://github.com/eddyizm/tempo/discussions</string>
|
||||
<string name="settings_support_summary">Join community discussions and support</string>
|
||||
<string name="settings_support_title">User support</string>
|
||||
<string name="settings_scan_result">Scanning: counting %1$d tracks</string>
|
||||
<string name="settings_image_size">Set image resolution</string>
|
||||
<string name="settings_language">Language</string>
|
||||
<string name="settings_logout_title">Log out</string>
|
||||
@@ -404,6 +410,22 @@
|
||||
<string name="share_bottom_sheet_update">Update share</string>
|
||||
<string name="share_subtitle_item">Expiration date: %1$s</string>
|
||||
<string name="share_unsupported_error">Sharing is not supported or not enabled</string>
|
||||
<string name="asset_link_clipboard_label">Tempo asset link</string>
|
||||
<string name="asset_link_label_song">Song UID</string>
|
||||
<string name="asset_link_label_album">Album UID</string>
|
||||
<string name="asset_link_label_artist">Artist UID</string>
|
||||
<string name="asset_link_label_playlist">Playlist UID</string>
|
||||
<string name="asset_link_label_genre">Genre UID</string>
|
||||
<string name="asset_link_label_year">Year UID</string>
|
||||
<string name="asset_link_label_unknown">Asset UID</string>
|
||||
<string name="asset_link_error_unsupported">Unsupported asset link</string>
|
||||
<string name="asset_link_error_song">Song could not be opened</string>
|
||||
<string name="asset_link_error_album">Album could not be opened</string>
|
||||
<string name="asset_link_error_artist">Artist could not be opened</string>
|
||||
<string name="asset_link_error_playlist">Playlist could not be opened</string>
|
||||
<string name="asset_link_chip_text">%1$s • %2$s</string>
|
||||
<string name="asset_link_copied_toast">Copied %1$s to clipboard</string>
|
||||
<string name="asset_link_debug_toast">Asset link: %1$s</string>
|
||||
<string name="share_update_dialog_hint_description">Description</string>
|
||||
<string name="share_update_dialog_hint_expiration_date">Expiration date</string>
|
||||
<string name="share_update_dialog_negative_button">Cancel</string>
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
<PreferenceCategory app:title="@string/settings_title_general">
|
||||
<Preference
|
||||
android:layout_height="match_parent"
|
||||
android:key="equalizer"
|
||||
android:key="system_equalizer"
|
||||
android:summary="@string/settings_system_equalizer_summary"
|
||||
android:title="@string/settings_system_equalizer_title" />
|
||||
|
||||
<Preference
|
||||
android:layout_height="match_parent"
|
||||
android:key="app_equalizer"
|
||||
android:summary="@string/settings_app_equalizer_summary"
|
||||
android:title="@string/settings_app_equalizer" />
|
||||
|
||||
<Preference
|
||||
android:key="scan_library"
|
||||
android:title="@string/settings_scan_title" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.media3.session.*
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil
|
||||
import com.cappielloantonio.tempo.util.Constants
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil
|
||||
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
|
||||
@@ -421,7 +422,14 @@ class MediaService : MediaLibraryService() {
|
||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||
val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
|
||||
val extras = mi?.mediaMetadata?.extras
|
||||
val coverId = extras?.getString("coverArtId")
|
||||
val songLink = extras?.getString("assetLinkSong")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
||||
val albumLink = extras?.getString("assetLinkAlbum")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
||||
val artistLink = extras?.getString("assetLinkArtist")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
WidgetUpdateManager.updateFromState(
|
||||
@@ -434,7 +442,10 @@ class MediaService : MediaLibraryService() {
|
||||
player.shuffleModeEnabled,
|
||||
player.repeatMode,
|
||||
position,
|
||||
duration
|
||||
duration,
|
||||
songLink,
|
||||
albumLink,
|
||||
artistLink
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.session.LibraryResult
|
||||
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
||||
import com.cappielloantonio.tempo.util.Preferences.getServerId
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
|
||||
object MediaBrowserTree {
|
||||
|
||||
private lateinit var automotiveRepository: AutomotiveRepository
|
||||
|
||||
private var treeNodes: MutableMap<String, MediaItemNode> = mutableMapOf()
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
// Root
|
||||
private const val ROOT_ID = "[rootID]"
|
||||
|
||||
// First level
|
||||
private const val HOME_ID = "[homeID]"
|
||||
private const val LIBRARY_ID = "[libraryID]"
|
||||
private const val OTHER_ID = "[otherID]"
|
||||
|
||||
// Second level HOME_ID
|
||||
private const val MOST_PLAYED_ID = "[mostPlayedID]"
|
||||
private const val LAST_PLAYED_ID = "[lastPlayedID]"
|
||||
private const val RECENTLY_ADDED_ID = "[recentlyAddedID]"
|
||||
private const val RECENT_SONGS_ID = "[recentSongsID]"
|
||||
private const val MADE_FOR_YOU_ID = "[madeForYouID]"
|
||||
private const val STARRED_TRACKS_ID = "[starredTracksID]"
|
||||
private const val STARRED_ALBUMS_ID = "[starredAlbumsID]"
|
||||
private const val STARRED_ARTISTS_ID = "[starredArtistsID]"
|
||||
private const val RANDOM_ID = "[randomID]"
|
||||
|
||||
// Second level LIBRARY_ID
|
||||
private const val FOLDER_ID = "[folderID]"
|
||||
private const val INDEX_ID = "[indexID]"
|
||||
private const val DIRECTORY_ID = "[directoryID]"
|
||||
private const val PLAYLIST_ID = "[playlistID]"
|
||||
|
||||
// Second level OTHER_ID
|
||||
private const val PODCAST_ID = "[podcastID]"
|
||||
private const val RADIO_ID = "[radioID]"
|
||||
|
||||
private const val ALBUM_ID = "[albumID]"
|
||||
private const val ARTIST_ID = "[artistID]"
|
||||
|
||||
private class MediaItemNode(val item: MediaItem) {
|
||||
private val children: MutableList<MediaItem> = ArrayList()
|
||||
|
||||
fun addChild(childID: String) {
|
||||
this.children.add(treeNodes[childID]!!.item)
|
||||
}
|
||||
|
||||
fun getChildren(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val listenableFuture = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
|
||||
val libraryResult = LibraryResult.ofItemList(children, null)
|
||||
|
||||
listenableFuture.set(libraryResult)
|
||||
|
||||
return listenableFuture
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMediaItem(
|
||||
title: String,
|
||||
mediaId: String,
|
||||
isPlayable: Boolean,
|
||||
isBrowsable: Boolean,
|
||||
mediaType: @MediaMetadata.MediaType Int,
|
||||
subtitleConfigurations: List<SubtitleConfiguration> = mutableListOf(),
|
||||
album: String? = null,
|
||||
artist: String? = null,
|
||||
genre: String? = null,
|
||||
sourceUri: Uri? = null,
|
||||
imageUri: Uri? = null
|
||||
): MediaItem {
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setAlbumTitle(album)
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setGenre(genre)
|
||||
.setIsBrowsable(isBrowsable)
|
||||
.setIsPlayable(isPlayable)
|
||||
.setArtworkUri(imageUri)
|
||||
.setMediaType(mediaType)
|
||||
.build()
|
||||
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.setMediaMetadata(metadata)
|
||||
.setUri(sourceUri)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun initialize(automotiveRepository: AutomotiveRepository) {
|
||||
this.automotiveRepository = automotiveRepository
|
||||
|
||||
if (isInitialized) return
|
||||
|
||||
isInitialized = true
|
||||
|
||||
// Root level
|
||||
|
||||
treeNodes[ROOT_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Root Folder",
|
||||
mediaId = ROOT_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
// First level
|
||||
|
||||
treeNodes[HOME_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Home",
|
||||
mediaId = HOME_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[LIBRARY_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Library",
|
||||
mediaId = LIBRARY_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[OTHER_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Other",
|
||||
mediaId = OTHER_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[ROOT_ID]!!.addChild(HOME_ID)
|
||||
treeNodes[ROOT_ID]!!.addChild(LIBRARY_ID)
|
||||
treeNodes[ROOT_ID]!!.addChild(OTHER_ID)
|
||||
|
||||
// Second level HOME_ID
|
||||
|
||||
treeNodes[MOST_PLAYED_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Most played",
|
||||
mediaId = MOST_PLAYED_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[LAST_PLAYED_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Last played",
|
||||
mediaId = LAST_PLAYED_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[RECENTLY_ADDED_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Recently added",
|
||||
mediaId = RECENTLY_ADDED_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[RECENT_SONGS_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Recent songs",
|
||||
mediaId = RECENT_SONGS_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[MADE_FOR_YOU_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Made for you",
|
||||
mediaId = MADE_FOR_YOU_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[STARRED_TRACKS_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Starred tracks",
|
||||
mediaId = STARRED_TRACKS_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[STARRED_ALBUMS_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Starred albums",
|
||||
mediaId = STARRED_ALBUMS_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[STARRED_ARTISTS_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Starred artists",
|
||||
mediaId = STARRED_ARTISTS_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[RANDOM_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Random",
|
||||
mediaId = RANDOM_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[HOME_ID]!!.addChild(MOST_PLAYED_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(LAST_PLAYED_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(RECENTLY_ADDED_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(RECENT_SONGS_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(MADE_FOR_YOU_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(STARRED_TRACKS_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(STARRED_ALBUMS_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(STARRED_ARTISTS_ID)
|
||||
treeNodes[HOME_ID]!!.addChild(RANDOM_ID)
|
||||
|
||||
// Second level LIBRARY_ID
|
||||
|
||||
treeNodes[FOLDER_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Folders",
|
||||
mediaId = FOLDER_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[PLAYLIST_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Playlists",
|
||||
mediaId = PLAYLIST_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[LIBRARY_ID]!!.addChild(FOLDER_ID)
|
||||
treeNodes[LIBRARY_ID]!!.addChild(PLAYLIST_ID)
|
||||
|
||||
// Second level OTHER_ID
|
||||
|
||||
treeNodes[PODCAST_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Podcasts",
|
||||
mediaId = PODCAST_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[RADIO_ID] =
|
||||
MediaItemNode(
|
||||
buildMediaItem(
|
||||
title = "Radio stations",
|
||||
mediaId = RADIO_ID,
|
||||
isPlayable = false,
|
||||
isBrowsable = true,
|
||||
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_RADIO_STATIONS
|
||||
)
|
||||
)
|
||||
|
||||
treeNodes[OTHER_ID]!!.addChild(PODCAST_ID)
|
||||
treeNodes[OTHER_ID]!!.addChild(RADIO_ID)
|
||||
}
|
||||
|
||||
fun getRootItem(): MediaItem {
|
||||
return treeNodes[ROOT_ID]!!.item
|
||||
}
|
||||
|
||||
fun getChildren(
|
||||
id: String
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
return when (id) {
|
||||
ROOT_ID -> treeNodes[ROOT_ID]?.getChildren()!!
|
||||
HOME_ID -> treeNodes[HOME_ID]?.getChildren()!!
|
||||
LIBRARY_ID -> treeNodes[LIBRARY_ID]?.getChildren()!!
|
||||
OTHER_ID -> treeNodes[OTHER_ID]?.getChildren()!!
|
||||
|
||||
MOST_PLAYED_ID -> automotiveRepository.getAlbums(id, "frequent", 100)
|
||||
LAST_PLAYED_ID -> automotiveRepository.getAlbums(id, "recent", 100)
|
||||
RECENTLY_ADDED_ID -> automotiveRepository.getAlbums(id, "newest", 100)
|
||||
RECENT_SONGS_ID -> automotiveRepository.getRecentlyPlayedSongs(getServerId(),100)
|
||||
MADE_FOR_YOU_ID -> automotiveRepository.getStarredArtists(id)
|
||||
STARRED_TRACKS_ID -> automotiveRepository.starredSongs
|
||||
STARRED_ALBUMS_ID -> automotiveRepository.getStarredAlbums(id)
|
||||
STARRED_ARTISTS_ID -> automotiveRepository.getStarredArtists(id)
|
||||
RANDOM_ID -> automotiveRepository.getRandomSongs(100)
|
||||
FOLDER_ID -> automotiveRepository.getMusicFolders(id)
|
||||
PLAYLIST_ID -> automotiveRepository.getPlaylists(id)
|
||||
PODCAST_ID -> automotiveRepository.getNewestPodcastEpisodes(100)
|
||||
RADIO_ID -> automotiveRepository.internetRadioStations
|
||||
|
||||
else -> {
|
||||
if (id.startsWith(MOST_PLAYED_ID)) {
|
||||
return automotiveRepository.getAlbumTracks(
|
||||
id.removePrefix(
|
||||
MOST_PLAYED_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(LAST_PLAYED_ID)) {
|
||||
return automotiveRepository.getAlbumTracks(
|
||||
id.removePrefix(
|
||||
LAST_PLAYED_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(RECENTLY_ADDED_ID)) {
|
||||
return automotiveRepository.getAlbumTracks(
|
||||
id.removePrefix(
|
||||
RECENTLY_ADDED_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(MADE_FOR_YOU_ID)) {
|
||||
return automotiveRepository.getMadeForYou(
|
||||
id.removePrefix(
|
||||
MADE_FOR_YOU_ID
|
||||
),
|
||||
20
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(STARRED_ALBUMS_ID)) {
|
||||
return automotiveRepository.getAlbumTracks(
|
||||
id.removePrefix(
|
||||
STARRED_ALBUMS_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(STARRED_ARTISTS_ID)) {
|
||||
return automotiveRepository.getArtistAlbum(
|
||||
STARRED_ALBUMS_ID,
|
||||
id.removePrefix(
|
||||
STARRED_ARTISTS_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(FOLDER_ID)) {
|
||||
return automotiveRepository.getIndexes(
|
||||
INDEX_ID,
|
||||
id.removePrefix(
|
||||
FOLDER_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(INDEX_ID)) {
|
||||
return automotiveRepository.getDirectories(
|
||||
DIRECTORY_ID,
|
||||
id.removePrefix(
|
||||
INDEX_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(DIRECTORY_ID)) {
|
||||
return automotiveRepository.getDirectories(
|
||||
DIRECTORY_ID,
|
||||
id.removePrefix(
|
||||
DIRECTORY_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(PLAYLIST_ID)) {
|
||||
return automotiveRepository.getPlaylistSongs(
|
||||
id.removePrefix(
|
||||
PLAYLIST_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(ALBUM_ID)) {
|
||||
return automotiveRepository.getAlbumTracks(
|
||||
id.removePrefix(
|
||||
ALBUM_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith(ARTIST_ID)) {
|
||||
return automotiveRepository.getArtistAlbum(
|
||||
ALBUM_ID,
|
||||
id.removePrefix(
|
||||
ARTIST_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/androidx/media/issues/156
|
||||
fun getItems(mediaItems: List<MediaItem>): List<MediaItem> {
|
||||
val updatedMediaItems = ArrayList<MediaItem>()
|
||||
|
||||
mediaItems.forEach {
|
||||
if (it.localConfiguration?.uri != null) {
|
||||
updatedMediaItems.add(it)
|
||||
} else {
|
||||
val sessionMediaItem = automotiveRepository.getSessionMediaItem(it.mediaId)
|
||||
|
||||
if (sessionMediaItem != null) {
|
||||
var toAdd = automotiveRepository.getMetadatas(sessionMediaItem.timestamp!!)
|
||||
val index = toAdd.indexOfFirst { mediaItem -> mediaItem.mediaId == it.mediaId }
|
||||
|
||||
toAdd = toAdd.subList(index, toAdd.size)
|
||||
|
||||
updatedMediaItems.addAll(toAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMediaItems
|
||||
}
|
||||
|
||||
fun search(query: String): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
return automotiveRepository.search(
|
||||
query,
|
||||
ALBUM_ID,
|
||||
ARTIST_ID
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
open class MediaLibrarySessionCallback(
|
||||
context: Context,
|
||||
automotiveRepository: AutomotiveRepository
|
||||
) :
|
||||
MediaLibraryService.MediaLibrarySession.Callback {
|
||||
|
||||
init {
|
||||
MediaBrowserTree.initialize(automotiveRepository)
|
||||
}
|
||||
|
||||
private val shuffleCommandButtons: List<CommandButton> = listOf(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
|
||||
.setSessionCommand(
|
||||
SessionCommand(
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
|
||||
)
|
||||
).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
|
||||
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
|
||||
.setSessionCommand(
|
||||
SessionCommand(
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
|
||||
)
|
||||
).setIconResId(R.drawable.exo_icon_shuffle_on).build()
|
||||
)
|
||||
|
||||
private val repeatCommandButtons: List<CommandButton> = listOf(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
|
||||
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.exo_icon_repeat_off)
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
|
||||
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.exo_icon_repeat_one)
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
|
||||
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.exo_icon_repeat_all)
|
||||
.build()
|
||||
)
|
||||
|
||||
private val customLayoutCommandButtons: List<CommandButton> =
|
||||
shuffleCommandButtons + repeatCommandButtons
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
val mediaNotificationSessionCommands =
|
||||
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
.also { builder ->
|
||||
(shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { builder.add(it) }
|
||||
}
|
||||
}.build()
|
||||
|
||||
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
|
||||
val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
|
||||
val repeat = when (player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
|
||||
Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
|
||||
else -> repeatCommandButtons[0]
|
||||
}
|
||||
return ImmutableList.of(shuffle, repeat)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onConnect(
|
||||
session: MediaSession, controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
|
||||
controller
|
||||
) || session.isAutoCompanionController(controller)
|
||||
) {
|
||||
val customLayout = buildCustomLayout(session.player)
|
||||
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(mediaNotificationSessionCommands)
|
||||
.setCustomLayout(customLayout).build()
|
||||
}
|
||||
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
when (customCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
|
||||
val nextMode = when (session.player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
session.player.repeatMode = nextMode
|
||||
}
|
||||
else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
|
||||
}
|
||||
|
||||
session.setCustomLayout(
|
||||
session.mediaNotificationControllerInfo!!,
|
||||
buildCustomLayout(session.player)
|
||||
)
|
||||
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(MediaBrowserTree.getRootItem(), params))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
return MediaBrowserTree.getChildren(parentId)
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
return super.onAddMediaItems(
|
||||
mediaSession,
|
||||
controller,
|
||||
MediaBrowserTree.getItems(mediaItems)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<Void>> {
|
||||
session.notifySearchResultChanged(browser, query, 60, params)
|
||||
return Futures.immediateFuture(LibraryResult.ofVoid())
|
||||
}
|
||||
|
||||
override fun onGetSearchResult(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
return MediaBrowserTree.search(query)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||
"android.media3.session.demo.SHUFFLE_ON"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||
"android.media3.session.demo.SHUFFLE_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
|
||||
"android.media3.session.demo.REPEAT_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
|
||||
"android.media3.session.demo.REPEAT_ONE"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
|
||||
"android.media3.session.demo.REPEAT_ALL"
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.cast.CastPlayer
|
||||
import androidx.media3.cast.SessionAvailabilityListener
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||
import com.cappielloantonio.tempo.util.Constants
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil
|
||||
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
import com.cappielloantonio.tempo.util.ReplayGainUtil
|
||||
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
|
||||
@UnstableApi
|
||||
class MediaService : MediaLibraryService(), SessionAvailabilityListener {
|
||||
private lateinit var automotiveRepository: AutomotiveRepository
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var castPlayer: CastPlayer
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
|
||||
lateinit var equalizerManager: EqualizerManager
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getEqualizerManager(): EqualizerManager {
|
||||
return this@MediaService.equalizerManager
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
companion object {
|
||||
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
||||
}
|
||||
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
||||
private var widgetUpdateScheduled = false
|
||||
private val widgetUpdateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (!player.isPlaying) {
|
||||
widgetUpdateScheduled = false
|
||||
return
|
||||
}
|
||||
updateWidget()
|
||||
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
initializeRepository()
|
||||
initializePlayer()
|
||||
initializeCastPlayer()
|
||||
initializeMediaLibrarySession()
|
||||
initializePlayerListener()
|
||||
initializeEqualizerManager()
|
||||
|
||||
setPlayer(
|
||||
null,
|
||||
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaLibrarySession.player
|
||||
|
||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
equalizerManager.release()
|
||||
stopWidgetUpdates()
|
||||
releasePlayer()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// Check if the intent is for our custom equalizer binder
|
||||
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
||||
return binder
|
||||
}
|
||||
// Otherwise, handle it as a normal MediaLibraryService connection
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun initializeRepository() {
|
||||
automotiveRepository = AutomotiveRepository()
|
||||
}
|
||||
|
||||
private fun initializePlayer() {
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setRenderersFactory(getRenderersFactory())
|
||||
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
|
||||
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setLoadControl(initializeLoadControl())
|
||||
.build()
|
||||
|
||||
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
||||
player.repeatMode = Preferences.getRepeatMode()
|
||||
}
|
||||
|
||||
private fun initializeEqualizerManager() {
|
||||
equalizerManager = EqualizerManager()
|
||||
val audioSessionId = player.audioSessionId
|
||||
if (equalizerManager.attachToSession(audioSessionId)) {
|
||||
val enabled = Preferences.isEqualizerEnabled()
|
||||
equalizerManager.setEnabled(enabled)
|
||||
|
||||
val bands = equalizerManager.getNumberOfBands()
|
||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||
for (i in 0 until bands) {
|
||||
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeCastPlayer() {
|
||||
if (GoogleApiAvailability.getInstance()
|
||||
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
|
||||
) {
|
||||
castPlayer = CastPlayer(CastContext.getSharedInstance(this))
|
||||
castPlayer.setSessionAvailabilityListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeMediaLibrarySession() {
|
||||
val sessionActivityPendingIntent =
|
||||
TaskStackBuilder.create(this).run {
|
||||
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
|
||||
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
librarySessionCallback = createLibrarySessionCallback()
|
||||
mediaLibrarySession =
|
||||
MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setSessionActivity(sessionActivityPendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
|
||||
return MediaLibrarySessionCallback(this, automotiveRepository)
|
||||
}
|
||||
|
||||
private fun initializePlayerListener() {
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (mediaItem == null) return
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
||||
MediaManager.setLastPlayedTimestamp(mediaItem)
|
||||
}
|
||||
updateWidget()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
ReplayGainUtil.setReplayGain(player, tracks)
|
||||
val currentMediaItem = player.currentMediaItem
|
||||
if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
|
||||
MediaManager.scrobble(currentMediaItem, false)
|
||||
}
|
||||
|
||||
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
|
||||
MediaManager.continuousPlay(player.currentMediaItem)
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (!isPlaying) {
|
||||
MediaManager.setPlayingPausedTimestamp(
|
||||
player.currentMediaItem,
|
||||
player.currentPosition
|
||||
)
|
||||
} else {
|
||||
MediaManager.scrobble(player.currentMediaItem, false)
|
||||
}
|
||||
if (isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
} else {
|
||||
stopWidgetUpdates()
|
||||
}
|
||||
updateWidget()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
if (!player.hasNextMediaItem() &&
|
||||
playbackState == Player.STATE_ENDED &&
|
||||
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
||||
) {
|
||||
MediaManager.scrobble(player.currentMediaItem, true)
|
||||
MediaManager.saveChronology(player.currentMediaItem)
|
||||
}
|
||||
updateWidget()
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
|
||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
||||
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.scrobble(oldPosition.mediaItem, true)
|
||||
MediaManager.saveChronology(oldPosition.mediaItem)
|
||||
}
|
||||
|
||||
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
||||
mediaLibrarySession.setCustomLayout(
|
||||
librarySessionCallback.buildCustomLayout(player)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
||||
Preferences.setRepeatMode(repeatMode)
|
||||
mediaLibrarySession.setCustomLayout(
|
||||
librarySessionCallback.buildCustomLayout(player)
|
||||
)
|
||||
}
|
||||
})
|
||||
if (player.isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateWidget() {
|
||||
val mi = player.currentMediaItem
|
||||
val title = mi?.mediaMetadata?.title?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("title")
|
||||
val artist = mi?.mediaMetadata?.artist?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||
val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
|
||||
|
||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
WidgetUpdateManager.updateFromState(
|
||||
this,
|
||||
title ?: "",
|
||||
artist ?: "",
|
||||
album ?: "",
|
||||
coverId,
|
||||
player.isPlaying,
|
||||
player.shuffleModeEnabled,
|
||||
player.repeatMode,
|
||||
position,
|
||||
duration
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleWidgetUpdates() {
|
||||
if (widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
||||
widgetUpdateScheduled = true
|
||||
}
|
||||
|
||||
private fun stopWidgetUpdates() {
|
||||
if (!widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
||||
widgetUpdateScheduled = false
|
||||
}
|
||||
|
||||
private fun initializeLoadControl(): DefaultLoadControl {
|
||||
return DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getQueueFromPlayer(player: Player): List<MediaItem> {
|
||||
val queue = mutableListOf<MediaItem>()
|
||||
for (i in 0 until player.mediaItemCount) {
|
||||
queue.add(player.getMediaItemAt(i))
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
private fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
|
||||
if (oldPlayer === newPlayer) return
|
||||
|
||||
oldPlayer?.stop()
|
||||
mediaLibrarySession.player = newPlayer
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null)
|
||||
if (this::castPlayer.isInitialized) castPlayer.release()
|
||||
player.release()
|
||||
mediaLibrarySession.release()
|
||||
automotiveRepository.deleteMetadata()
|
||||
}
|
||||
|
||||
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
||||
|
||||
override fun onCastSessionAvailable() {
|
||||
val currentQueue = getQueueFromPlayer(player)
|
||||
val currentIndex = player.currentMediaItemIndex
|
||||
val currentPosition = player.currentPosition
|
||||
val isPlaying = player.playWhenReady
|
||||
|
||||
setPlayer(player, castPlayer)
|
||||
|
||||
castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
|
||||
castPlayer.playWhenReady = isPlaying
|
||||
castPlayer.prepare()
|
||||
}
|
||||
|
||||
override fun onCastSessionUnavailable() {
|
||||
val currentQueue = getQueueFromPlayer(castPlayer)
|
||||
val currentIndex = castPlayer.currentMediaItemIndex
|
||||
val currentPosition = castPlayer.currentPosition
|
||||
val isPlaying = castPlayer.playWhenReady
|
||||
|
||||
setPlayer(castPlayer, player)
|
||||
|
||||
player.setMediaItems(currentQueue, currentIndex, currentPosition)
|
||||
player.playWhenReady = isPlaying
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.FragmentToolbarBinding;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.google.android.gms.cast.framework.CastButtonFactory;
|
||||
|
||||
@UnstableApi
|
||||
public class ToolbarFragment extends Fragment {
|
||||
private static final String TAG = "ToolbarFragment";
|
||||
|
||||
private FragmentToolbarBinding bind;
|
||||
private MainActivity activity;
|
||||
|
||||
public ToolbarFragment() {
|
||||
// Required empty public constructor
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
inflater.inflate(R.menu.main_page_menu, menu);
|
||||
CastButtonFactory.setUpMediaRouteButton(requireContext(), menu, R.id.media_route_menu_item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
activity = (MainActivity) getActivity();
|
||||
|
||||
bind = FragmentToolbarBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.action_search) {
|
||||
activity.navController.navigate(R.id.searchFragment);
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.action_settings) {
|
||||
activity.navController.navigate(R.id.settingsFragment);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.cappielloantonio.tempo.util;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.google.android.gms.cast.framework.CastContext;
|
||||
import com.google.android.gms.common.ConnectionResult;
|
||||
import com.google.android.gms.common.GoogleApiAvailability;
|
||||
|
||||
public class Flavors {
|
||||
public static void initializeCastContext(Context context) {
|
||||
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS)
|
||||
CastContext.getSharedInstance(context);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil
|
||||
import com.cappielloantonio.tempo.util.Constants
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil
|
||||
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
|
||||
@@ -262,7 +263,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
|
||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||
val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
|
||||
val extras = mi?.mediaMetadata?.extras
|
||||
val coverId = extras?.getString("coverArtId")
|
||||
val songLink = extras?.getString("assetLinkSong")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
||||
val albumLink = extras?.getString("assetLinkAlbum")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
||||
val artistLink = extras?.getString("assetLinkArtist")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
WidgetUpdateManager.updateFromState(
|
||||
@@ -275,7 +283,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
|
||||
player.shuffleModeEnabled,
|
||||
player.repeatMode,
|
||||
position,
|
||||
duration
|
||||
duration,
|
||||
songLink,
|
||||
albumLink,
|
||||
artistLink
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
BIN
mockup/usage/player_icons.png
Normal file
BIN
mockup/usage/player_icons.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Reference in New Issue
Block a user