This commit is contained in:
eddyizm 2025-09-23 15:23:41 -07:00 committed by GitHub
commit 7321ef46f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1943 additions and 162 deletions

View file

@ -10,9 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 31
versionName '3.14.8'
versionCode 32
versionName '3.15.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
@ -23,8 +22,21 @@ android {
]
}
}
}
splits {
abi {
enable true
reset()
//noinspection ChromeOsAbiSupport
include 'armeabi-v7a', 'arm64-v8a'
universalApk false
}
}
flavorDimensions += "default"
productFlavors {
@ -50,6 +62,12 @@ android {
minifyEnabled true
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
universalApk true
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}

View file

@ -0,0 +1,47 @@
package com.cappielloantonio.tempo.service
import android.media.audiofx.Equalizer
class EqualizerManager {
private var equalizer: Equalizer? = null
fun attachToSession(audioSessionId: Int): Boolean {
release()
if (audioSessionId != 0 && audioSessionId != -1) {
try {
equalizer = Equalizer(0, audioSessionId).apply {
enabled = true
}
return true
} catch (e: Exception) {
// Some devices may not support Equalizer or audio session may be invalid
equalizer = null
}
}
return false
}
fun setBandLevel(band: Short, level: Short) {
equalizer?.setBandLevel(band, level)
}
fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0
fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange
fun getCenterFreq(band: Short): Int? =
equalizer?.getCenterFreq(band)?.div(1000)
fun getBandLevel(band: Short): Short? =
equalizer?.getBandLevel(band)
fun setEnabled(enabled: Boolean) {
equalizer?.enabled = enabled
}
fun release() {
equalizer?.release()
equalizer = null
}
}

View file

@ -1,11 +1,17 @@
package com.cappielloantonio.tempo.service;
import android.content.ComponentName;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
@ -21,14 +27,79 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.concurrent.ExecutionException;
public class MediaManager {
private static final String TAG = "MediaManager";
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
public static void registerPlaybackObserver(
ListenableFuture<MediaBrowser> browserFuture,
PlaybackViewModel playbackViewModel
) {
if (browserFuture == null) return;
Futures.addCallback(browserFuture, new FutureCallback<MediaBrowser>() {
@Override
public void onSuccess(MediaBrowser browser) {
MediaBrowser current = attachedBrowserRef.get();
if (current != browser) {
browser.addListener(new Player.Listener() {
@Override
public void onEvents(@NonNull Player player, @NonNull Player.Events events) {
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)
|| events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
|| events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
String mediaId = player.getCurrentMediaItem() != null
? player.getCurrentMediaItem().mediaId
: null;
boolean playing = player.getPlaybackState() == Player.STATE_READY
&& player.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
}
}
});
String mediaId = browser.getCurrentMediaItem() != null
? browser.getCurrentMediaItem().mediaId
: null;
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
attachedBrowserRef = new WeakReference<>(browser);
} else {
String mediaId = browser.getCurrentMediaItem() != null
? browser.getCurrentMediaItem().mediaId
: null;
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
}
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.e(TAG, "Failed to get MediaBrowser instance", t);
}
}, MoreExecutors.directExecutor());
}
public static void onBrowserReleased(@Nullable MediaBrowser released) {
MediaBrowser attached = attachedBrowserRef.get();
if (attached == released) {
attachedBrowserRef.clear();
}
}
public static void reset(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
if (mediaBrowserListenableFuture != null) {
@ -107,11 +178,24 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().clearMediaItems();
mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media));
mediaBrowserListenableFuture.get().prepare();
mediaBrowserListenableFuture.get().seekTo(startIndex, 0);
mediaBrowserListenableFuture.get().play();
MediaBrowser browser = mediaBrowserListenableFuture.get();
browser.clearMediaItems();
browser.setMediaItems(MappingUtil.mapMediaItems(media));
browser.prepare();
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
}
}
};
browser.addListener(timelineListener);
enqueueDatabase(media, true, 0);
}
} catch (ExecutionException | InterruptedException e) {

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -23,17 +24,24 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
private static final String TAG = "PlayerSongQueueAdapter";
private final ClickCallback click;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private List<Child> songs;
private String currentPlayingId;
private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList();
public PlayerSongQueueAdapter(ClickCallback click) {
this.click = click;
this.songs = Collections.emptyList();
@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
} else {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
}
holder.itemView.setOnClickListener(v -> {
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int pos = holder.getBindingAdapterPosition();
Child s = songs.get(pos);
if (currentPlayingId != null && currentPlayingId.equals(s.getId())) {
if (isPlaying) {
mediaBrowser.pause();
} else {
mediaBrowser.play();
}
} else {
mediaBrowser.seekTo(pos, 0);
mediaBrowser.play();
}
} catch (Exception e) {
Log.w(TAG, "Error obtaining MediaBrowser", e);
}
}, MoreExecutors.directExecutor());
});
bindPlaybackState(holder, song);
}
private void bindPlaybackState(@NonNull PlayerSongQueueAdapter.ViewHolder holder, @NonNull Child song) {
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
if (isCurrent) {
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
if (isPlaying) {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
} else {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
}
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
} else {
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
}
}
public List<Child> getItems() {
@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
}
public void setPlaybackState(String mediaId, boolean playing) {
String oldId = this.currentPlayingId;
boolean oldPlaying = this.isPlaying;
List<Integer> oldPositions = currentPlayingPositions;
this.currentPlayingId = mediaId;
this.isPlaying = playing;
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
if (oldPositions.equals(newPositionsCheck)) {
return;
}
}
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
for (int pos : oldPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
for (int pos : currentPlayingPositions) {
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
private List<Integer> findPositionsById(String id) {
if (id == null) return Collections.emptyList();
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < songs.size(); i++) {
if (id.equals(songs.get(i).getId())) {
positions.add(i);
}
}
return positions;
}
public Child getItem(int id) {
return songs.get(id);
}

View file

@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.ui.adapter;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -10,6 +12,7 @@ import android.widget.Filterable;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.R;
@ -23,6 +26,7 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
@ -30,6 +34,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@UnstableApi
public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAdapter.ViewHolder> implements Filterable {
@ -42,6 +47,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private List<Child> songs;
private String currentFilter;
private String currentPlayingId;
private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList();
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private final Filter filtering = new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
@ -70,6 +80,12 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
protected void publishResults(CharSequence constraint, FilterResults results) {
songs = (List<Child>) results.values;
notifyDataSetChanged();
for (int pos : currentPlayingPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
};
@ -81,6 +97,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
this.songsFull = Collections.emptyList();
this.currentFilter = "";
this.album = album;
setHasStableIds(false);
}
@NonNull
@ -91,7 +108,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (!payloads.isEmpty() && payloads.contains("payload_playback")) {
bindPlaybackState(holder, songs.get(position));
} else {
super.onBindViewHolder(holder, position, payloads);
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Child song = songs.get(position);
holder.item.searchResultSongTitleTextView.setText(song.getTitle());
@ -165,6 +191,33 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
} else {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
}
bindPlaybackState(holder, song);
}
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
if (isCurrent) {
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
if (isPlaying) {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
} else {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
}
if (!showCoverArt) {
holder.item.trackNumberTextView.setVisibility(View.INVISIBLE);
} else {
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
}
} else {
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
if (!showCoverArt) {
holder.item.trackNumberTextView.setVisibility(View.VISIBLE);
} else {
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
}
}
}
@Override
@ -188,6 +241,46 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
return position;
}
public void setPlaybackState(String mediaId, boolean playing) {
String oldId = this.currentPlayingId;
boolean oldPlaying = this.isPlaying;
List<Integer> oldPositions = currentPlayingPositions;
this.currentPlayingId = mediaId;
this.isPlaying = playing;
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
if (oldPositions.equals(newPositionsCheck)) {
return;
}
}
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
for (int pos : oldPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
for (int pos : currentPlayingPositions) {
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
private List<Integer> findPositionsById(String id) {
if (id == null) return Collections.emptyList();
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < songs.size(); i++) {
if (id.equals(songs.get(i).getId())) {
positions.add(i);
}
}
return positions;
}
@Override
public Filter getFilter() {
return filtering;
@ -215,11 +308,29 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
}
public void onClick() {
int pos = getBindingAdapterPosition();
Child tappedSong = songs.get(pos);
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(MusicUtil.limitPlayableMedia(songs, getBindingAdapterPosition())));
bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition()));
click.onMediaClick(bundle);
if (tappedSong.getId().equals(currentPlayingId)) {
Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback");
try{
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying);
if (isPlaying) {
mediaBrowser.pause();
} else {
mediaBrowser.play();
}
} catch (ExecutionException | InterruptedException e) {
Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e);
}
} else {
click.onMediaClick(bundle);
}
}
private boolean onLongClick() {
@ -247,4 +358,8 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
notifyDataSetChanged();
}
public void setMediaBrowserListenableFuture(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
}
}

View file

@ -40,6 +40,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
@ -52,6 +53,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private FragmentAlbumPageBinding bind;
private MainActivity activity;
private AlbumPageViewModel albumPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@ -74,6 +76,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@ -91,6 +94,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@ -271,8 +282,13 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
});
}
});
}
@ -295,4 +311,31 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -29,19 +29,16 @@ import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumArtistPageOrSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@UnstableApi
@ -49,6 +46,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private FragmentArtistPageBinding bind;
private MainActivity activity;
private ArtistPageViewModel artistPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private AlbumCatalogueAdapter albumCatalogueAdapter;
@ -63,6 +61,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = FragmentArtistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@ -80,6 +79,13 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@ -174,6 +180,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
artistPageViewModel.getArtistTopSongList().observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE);
@ -183,6 +191,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
if (bind != null)
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
}
});
}
@ -273,4 +282,31 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -0,0 +1,237 @@
package com.cappielloantonio.tempo.ui.fragment
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.util.Preferences
class EqualizerFragment : Fragment() {
private var equalizerManager: EqualizerManager? = null
private lateinit var eqBandsContainer: LinearLayout
private lateinit var eqSwitch: Switch
private lateinit var resetButton: Button
private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>()
private val connection = object : ServiceConnection {
@OptIn(UnstableApi::class)
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as MediaService.LocalBinder
equalizerManager = binder.getEqualizerManager()
initUI()
restoreEqualizerPreferences()
}
override fun onServiceDisconnected(arg0: ComponentName) {
equalizerManager = null
}
}
@OptIn(UnstableApi::class)
override fun onStart() {
super.onStart()
Intent(requireContext(), MediaService::class.java).also { intent ->
intent.action = MediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
override fun onStop() {
super.onStop()
requireActivity().unbindService(connection)
equalizerManager = null
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val root = inflater.inflate(R.layout.fragment_equalizer, container, false)
eqSwitch = root.findViewById(R.id.equalizer_switch)
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
eqSwitch.jumpDrawablesToCurrentState()
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
eqBandsContainer = view.findViewById(R.id.eq_bands_container)
resetButton = view.findViewById(R.id.equalizer_reset_button)
safeSpace = view.findViewById(R.id.equalizer_bottom_space)
}
private fun initUI() {
val manager = equalizerManager
val notSupportedView = view?.findViewById<LinearLayout>(R.id.equalizer_not_supported_container)
val switchRow = view?.findViewById<View>(R.id.equalizer_switch_row)
if (manager == null || manager.getNumberOfBands().toInt() == 0) {
switchRow?.visibility = View.GONE
resetButton.visibility = View.GONE
eqBandsContainer.visibility = View.GONE
safeSpace.visibility = View.GONE
notSupportedView?.visibility = View.VISIBLE
return
}
notSupportedView?.visibility = View.GONE
switchRow?.visibility = View.VISIBLE
resetButton.visibility = View.VISIBLE
eqBandsContainer.visibility = View.VISIBLE
safeSpace.visibility = View.VISIBLE
eqSwitch.setOnCheckedChangeListener(null)
updateUiEnabledState(eqSwitch.isChecked)
eqSwitch.setOnCheckedChangeListener { _, isChecked ->
manager.setEnabled(isChecked)
Preferences.setEqualizerEnabled(isChecked)
updateUiEnabledState(isChecked)
}
createBandSliders()
resetButton.setOnClickListener {
resetEqualizer()
saveBandLevelsToPreferences()
}
}
private fun updateUiEnabledState(isEnabled: Boolean) {
resetButton.isEnabled = isEnabled
bandSeekBars.forEach { it.isEnabled = isEnabled }
}
private fun formatDb(value: Int): String = if (value > 0) "+$value dB" else "$value dB"
private fun createBandSliders() {
val manager = equalizerManager ?: return
eqBandsContainer.removeAllViews()
bandSeekBars.clear()
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val maxLevelDb = bandLevelRange[1] / 100
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
val band = i.toShort()
val freq = manager.getCenterFreq(band) ?: 0
val row = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
val topBottomMarginDp = 16
topMargin = topBottomMarginDp.dpToPx(context)
bottomMargin = topBottomMarginDp.dpToPx(context)
}
setPadding(0, 8, 0, 8)
}
val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
text = if (freq >= 1000) {
if (freq % 1000 == 0) {
"${freq / 1000} kHz"
} else {
String.format("%.1f kHz", freq / 1000f)
}
} else {
"$freq Hz"
}
gravity = Gravity.START
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
}
row.addView(freqLabel)
val initialLevelDb = (savedLevels.getOrNull(i) ?: (manager.getBandLevel(band) ?: 0)) / 100
val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
text = formatDb(initialLevelDb)
setPadding(12, 0, 0, 0)
gravity = Gravity.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
}
val seekBar = SeekBar(requireContext()).apply {
max = maxLevelDb - minLevelDb
progress = initialLevelDb - minLevelDb
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 6f)
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
val thisLevelDb = progress + minLevelDb
if (fromUser) {
manager.setBandLevel(band, (thisLevelDb * 100).toShort())
saveBandLevelsToPreferences()
}
dbLabel.text = formatDb(thisLevelDb)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
}
bandSeekBars.add(seekBar)
row.addView(seekBar)
row.addView(dbLabel)
eqBandsContainer.addView(row)
}
}
private fun resetEqualizer() {
val manager = equalizerManager ?: return
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val midLevelDb = 0
for (i in 0 until bands) {
manager.setBandLevel(i.toShort(), (0).toShort())
bandSeekBars.getOrNull(i)?.progress = midLevelDb - minLevelDb
}
Preferences.setEqualizerBandLevels(ShortArray(bands.toInt()))
}
private fun saveBandLevelsToPreferences() {
val manager = equalizerManager ?: return
val bands = manager.getNumberOfBands()
val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 }
Preferences.setEqualizerBandLevels(levels)
}
private fun restoreEqualizerPreferences() {
val manager = equalizerManager ?: return
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
updateUiEnabledState(eqSwitch.isChecked)
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
val savedDb = savedLevels[i] / 100
manager.setBandLevel(i.toShort(), (savedDb * 100).toShort())
bandSeekBars.getOrNull(i)?.progress = savedDb - minLevelDb
}
}
}
private fun Int.dpToPx(context: Context): Int =
(this * context.resources.displayMetrics.density).toInt()

View file

@ -60,6 +60,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
@ -74,6 +75,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private FragmentHomeTabMusicBinding bind;
private MainActivity activity;
private HomeViewModel homeViewModel;
private PlaybackViewModel playbackViewModel;
private DiscoverSongAdapter discoverSongAdapter;
private SimilarTrackAdapter similarMusicAdapter;
@ -101,6 +103,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind = FragmentHomeTabMusicBinding.inflate(inflater, container, false);
View view = bind.getRoot();
homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
@ -138,12 +141,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observeStarredSongsPlayback();
observeTopSongsPlayback();
}
@Override
public void onResume() {
super.onResume();
refreshSharesView();
if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture();
if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture();
}
@Override
@ -477,6 +486,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
setTopSongsMediaBrowserListenableFuture();
reapplyTopSongsPlayback();
homeViewModel.getChronologySample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), chronologies -> {
if (chronologies == null || chronologies.isEmpty()) {
if (bind != null) bind.homeGridTracksSector.setVisibility(View.GONE);
@ -492,6 +503,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
.collect(Collectors.toList());
topSongAdapter.setItems(topSongs);
reapplyTopSongsPlayback();
}
});
@ -515,6 +527,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
setStarredSongsMediaBrowserListenableFuture();
reapplyStarredSongsPlayback();
homeViewModel.getStarredTracks(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.starredTracksSector.setVisibility(View.GONE);
@ -525,6 +539,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false));
starredSongAdapter.setItems(songs);
reapplyStarredSongsPlayback();
}
});
@ -954,6 +969,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
activity.setBottomSheetInPeek(true);
}
topSongAdapter.notifyDataSetChanged();
starredSongAdapter.notifyDataSetChanged();
}
@Override
@ -1043,4 +1060,58 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
public void onShareLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle);
}
private void observeStarredSongsPlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (starredSongAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (starredSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void observeTopSongsPlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (topSongAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (topSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyStarredSongsPlayback() {
if (starredSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void reapplyTopSongsPlayback() {
if (topSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setTopSongsMediaBrowserListenableFuture() {
topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
private void setStarredSongsMediaBrowserListenableFuture() {
starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -1,7 +1,11 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
@ -24,11 +28,14 @@ import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
@ -68,11 +75,15 @@ public class PlayerControllerFragment extends Fragment {
private ImageButton playerOpenQueueButton;
private ImageButton playerTrackInfo;
private LinearLayout ratingContainer;
private ImageButton equalizerButton;
private MainActivity activity;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
@ -89,6 +100,7 @@ public class PlayerControllerFragment extends Fragment {
initMediaListenable();
initMediaLabelButton();
initArtistLabelButton();
initEqualizerButton();
return view;
}
@ -126,6 +138,7 @@ public class PlayerControllerFragment extends Fragment {
playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track);
songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar);
ratingContainer = bind.getRoot().findViewById(R.id.rating_container);
equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button);
checkAndSetRatingContainerVisibility();
}
@ -426,6 +439,18 @@ public class PlayerControllerFragment extends Fragment {
});
}
private void initEqualizerButton() {
equalizerButton.setOnClickListener(v -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
navController.navigate(R.id.equalizerFragment, null, navOptions);
if (activity != null) activity.collapseBottomSheetDelayed();
});
}
public void goToControllerPage() {
playerMediaCoverViewPager.setCurrentItem(0, false);
}
@ -461,4 +486,66 @@ public class PlayerControllerFragment extends Fragment {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
// TODO Resettare lo skip del silenzio
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
if (equalizerButton != null) {
if (numBands == 0) {
equalizerButton.setVisibility(View.GONE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
playerOpenQueueButton.setLayoutParams(params);
} else {
equalizerButton.setVisibility(View.VISIBLE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToStart = ConstraintLayout.LayoutParams.UNSET;
params.startToEnd = R.id.player_open_equalizer_button;
playerOpenQueueButton.setLayoutParams(params);
}
}
}
}
@Override
public void onResume() {
super.onResume();
bindMediaService();
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View file

@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@ -38,6 +39,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private PlaybackViewModel playbackViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private PlayerSongQueueAdapter playerSongQueueAdapter;
@ -48,6 +50,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot();
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initQueueRecyclerView();
@ -59,6 +62,9 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
super.onStart();
initializeBrowser();
bindMediaController();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
@ -110,9 +116,12 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter = new PlayerSongQueueAdapter(this);
bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter);
reapplyPlayback();
playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> {
if (queue != null) {
playerSongQueueAdapter.setItems(queue.stream().map(item -> (Child) item).collect(Collectors.toList()));
reapplyPlayback();
}
});
@ -216,4 +225,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (playerSongQueueAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (playerSongQueueAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (playerSongQueueAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
}
}

View file

@ -37,6 +37,7 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@ -49,6 +50,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
private FragmentPlaylistPageBinding bind;
private MainActivity activity;
private PlaylistPageViewModel playlistPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@ -94,6 +96,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind = FragmentPlaylistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
playlistPageViewModel = new ViewModelProvider(requireActivity()).get(PlaylistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@ -109,6 +112,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@ -248,8 +260,13 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
});
}
private void initializeMediaBrowser() {
@ -270,4 +287,31 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -4,14 +4,11 @@ import android.content.ComponentName;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -34,6 +31,7 @@ import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SearchViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@ -46,6 +44,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
private FragmentSearchBinding bind;
private MainActivity activity;
private SearchViewModel searchViewModel;
private PlaybackViewModel playbackViewModel;
private ArtistAdapter artistAdapter;
private AlbumAdapter albumAdapter;
@ -61,6 +60,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind = FragmentSearchBinding.inflate(inflater, container, false);
View view = bind.getRoot();
searchViewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initSearchResultView();
initSearchView();
@ -73,6 +73,15 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@ -113,6 +122,9 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
setMediaBrowserListenableFuture();
reapplyPlayback();
bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter);
}
@ -260,6 +272,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
@Override
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
songHorizontalAdapter.notifyDataSetChanged();
activity.setBottomSheetInPeek(true);
}
@ -287,4 +300,31 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -1,9 +1,13 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -18,6 +22,9 @@ import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
@ -28,6 +35,8 @@ import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.interfaces.ScanCallback;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
@ -51,6 +60,9 @@ public class SettingsFragment extends PreferenceFragmentCompat {
private ActivityResultLauncher<Intent> someActivityResultLauncher;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -86,7 +98,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onResume() {
super.onResume();
checkEqualizer();
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
@ -102,6 +114,9 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionChangeDownloadStorage();
actionDeleteDownloadStorage();
actionKeepScreenOn();
bindMediaService();
actionAppEqualizer();
}
@Override
@ -124,8 +139,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
private void checkEqualizer() {
Preference equalizer = findPreference("equalizer");
private void checkSystemEqualizer() {
Preference equalizer = findPreference("system_equalizer");
if (equalizer == null) return;
@ -353,4 +368,63 @@ public class SettingsFragment extends PreferenceFragmentCompat {
return true;
});
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setVisible(numBands > 0);
}
}
}
private void actionAppEqualizer() {
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setOnPreferenceClickListener(preference -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true);
navController.navigate(R.id.equalizerFragment, null, navOptions);
return true;
});
}
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View file

@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SongListPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@ -49,6 +50,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
private FragmentSongListPageBinding bind;
private MainActivity activity;
private SongListPageViewModel songListPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@ -69,6 +71,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind = FragmentSongListPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
songListPageViewModel = new ViewModelProvider(requireActivity()).get(SongListPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@ -82,6 +85,15 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
setMediaBrowserListenableFuture();
}
@Override
@ -191,9 +203,12 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> {
isLoading = false;
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
setSongListPageSubtitle(songs);
});
@ -325,4 +340,31 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View file

@ -78,32 +78,26 @@ public final class DownloadUtil {
return httpDataSourceFactory;
}
public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
if (dataSourceFactory == null) {
context = context.getApplicationContext();
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory;
}
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
if (Preferences.getStreamingCacheSize() > 0) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(upstreamFactory);
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
} else {
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context));
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
return dataSourceFactory;
}

View file

@ -0,0 +1,69 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
@UnstableApi
class DynamicMediaSourceFactory(
private val context: Context
) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true
streamingCacheSize > 0 && bypassCache -> true
streamingCacheSize > 0 && !bypassCache -> false
else -> true
}
val dataSourceFactory: DataSource.Factory = if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context)
} else {
DownloadUtil.getCacheDataSourceFactory(context)
}
return when {
mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 ||
mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> {
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
else -> {
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItem)
}
}
}
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun getSupportedTypes(): IntArray {
return intArrayOf(
C.CONTENT_TYPE_HLS,
C.CONTENT_TYPE_OTHER
)
}
}

View file

@ -69,7 +69,8 @@ object Preferences {
private const val NEXT_UPDATE_CHECK = "next_update_check"
private const val CONTINUOUS_PLAY = "continuous_play"
private const val LAST_INSTANT_MIX = "last_instant_mix"
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
@JvmStatic
fun getServer(): String? {
@ -538,4 +539,31 @@ object Preferences {
LAST_INSTANT_MIX, 0
) + 5000 < System.currentTimeMillis()
}
@JvmStatic
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
}
@JvmStatic
fun isEqualizerEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
}
@JvmStatic
fun setEqualizerBandLevels(bandLevels: ShortArray) {
val asString = bandLevels.joinToString(",")
App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
}
@JvmStatic
fun getEqualizerBandLevels(bandCount: Short): ShortArray {
val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
if (str.isNullOrBlank()) {
return ShortArray(bandCount.toInt())
}
val parts = str.split(",")
if (parts.size < bandCount) return ShortArray(bandCount.toInt())
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
}
}

View file

@ -0,0 +1,35 @@
package com.cappielloantonio.tempo.viewmodel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import java.util.Objects;
public class PlaybackViewModel extends ViewModel {
private final MutableLiveData<String> currentSongId = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> isPlaying = new MutableLiveData<>(false);
public LiveData<String> getCurrentSongId() {
return currentSongId;
}
public LiveData<Boolean> getIsPlaying() {
return isPlaying;
}
public void update(String songId, boolean playing) {
if (!Objects.equals(currentSongId.getValue(), songId)) {
currentSongId.postValue(songId);
}
if (!Objects.equals(isPlaying.getValue(), playing)) {
isPlaying.postValue(playing);
}
}
public void clear() {
currentSongId.postValue(null);
isPlaying.postValue(false);
}
}

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M160,800L160,480L320,480L320,800L160,800ZM400,800L400,160L560,160L560,800L400,800ZM640,800L640,360L800,360L800,800L640,800Z"/>
</vector>

View file

@ -0,0 +1,93 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="757.96dp"
android:height="743.73dp"
android:viewportWidth="757.96"
android:viewportHeight="743.73">
<path
android:pathData="M91.45,0a32.04,32.04 0,0 0,-32 32L59.45,710.43a32.04,32.04 0,0 0,32 32h297a32.04,32.04 0,0 0,32 -32L420.45,32a32.04,32.04 0,0 0,-32 -32Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M400.66,156.98v-54.44a125.25,125.25 0,0 1,-80.86 -60.19h0a23.79,23.79 0,0 1,-14.22 4.68L262.35,47.03A178.55,178.55 0,0 0,400.66 156.98Z"
android:fillColor="#fff"/>
<path
android:pathData="M400.66,99.42v-52.3a29.12,29.12 0,0 0,-29.13 -29.13h-41.97v5.05a23.92,23.92 0,0 1,-7.4 17.33,122.3 122.3,0 0,0 78.5,59.05Z"
android:fillColor="#fff"/>
<path
android:pathData="M198.77,47.03L171.74,47.03a23.99,23.99 0,0 1,-23.98 -23.99v-5.05L108.38,17.99a29.13,29.13 0,0 0,-29.13 29.13v648.2a29.08,29.08 0,0 0,29.13 29.11h263.15a28.36,28.36 0,0 0,3.59 -0.22,29.15 29.15,0 0,0 25.54,-28.89L400.66,218.15C304.95,207.07 225.2,138.77 198.77,47.03Z"
android:fillColor="#fff"/>
<path
android:pathData="M259.07,47.03h-57.14c26.3,90.04 104.68,157.03 198.73,168.07v-55.02A181.67,181.67 0,0 1,259.07 47.03Z"
android:fillColor="#fff"/>
<path
android:pathData="M380.61,532.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,460.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 532.78ZM110.61,457.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,460.81a3,3 0,0 0,-3 -3Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M145.61,494.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
android:fillColor="#3f3d56"/>
<path
android:pathData="M194.11,480.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M194.11,501.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M380.61,644.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,572.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 644.78ZM110.61,569.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,572.81a3,3 0,0 0,-3 -3Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M145.61,606.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
android:fillColor="#3f3d56"/>
<path
android:pathData="M194.11,592.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M194.11,613.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M239.93,394a94.96,94.96 0,0 1,-95 -95c0,-0.2 0,-0.41 0.01,-0.61 0.29,-52.03 42.9,-94.39 94.99,-94.39a95,95 0,1 1,0 190ZM239.93,206a93.2,93.2 0,0 0,-92.99 92.46c-0.01,0.21 -0.01,0.38 -0.01,0.54a93.01,93.01 0,1 0,93 -93Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M282.95,296.81l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M757.57,743.73H0v-2.18H757.96Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M590.68,338.14m-27.94,0a27.94,27.94 0,1 1,55.87 0a27.94,27.94 0,1 1,-55.87 0"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M588.87,494.75a12.51,12.51 0,0 1,9.47 -16.1,11.89 11.89,0 0,1 1.66,-0.2l29.43,-47.23L602.55,405.66A10.73,10.73 0,1 1,617.47 390.25l37.11,36.6 0.08,0.09a9.72,9.72 0,0 1,-0.68 11.58L612.75,487.28a11.73,11.73 0,0 1,0.31 1.19,12.51 12.51,0 0,1 -11.23,14.92q-0.53,0.05 -1.06,0.05A12.55,12.55 0,0 1,588.87 494.75Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M544.67,726.93L530.72,726.93l-6.63,-53.79 20.58,0Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M548.79,741.02l-46.1,0L502.69,739.88a18.07,18.07 0,0 1,18.07 -18.07h28.03Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M683.27,707.66l-11.98,7.14 -33.22,-42.82 17.68,-10.53Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M654.41,741.23l-0.58,-0.98a18.07,18.07 0,0 1,6.28 -24.77l24.08,-14.34 9.83,16.5Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M522.33,703.25c-9.34,-109.99 -14.9,-212.18 19.25,-253.86l0.26,-0.32 57.47,22.99 0.09,0.2c0.19,0.42 19.31,42.46 14.85,70.74l14.18,65.21 46.22,77.39a5.12,5.12 0,0 1,-2.33 7.31l-20.09,8.84a5.14,5.14 0,0 1,-6.42 -2.01L595.53,617.75l-28.4,-62.88a1.71,1.71 0,0 0,-3.25 0.52L548.14,703.36a5.11,5.11 0,0 1,-5.09 4.58L527.43,707.94A5.15,5.15 0,0 1,522.33 703.25Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M541.77,450.26l-0.27,-0.13 -0.04,-0.3c-2.15,-15.02 0.39,-31.72 7.55,-49.62a39.4,39.4 0,0 1,45.73 -23.59h0a39.35,39.35 0,0 1,25.09 19.3,38.92 38.92,0 0,1 2.7,31.19c-9.02,26.39 -20.73,51.08 -20.85,51.32l-0.25,0.51Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M500.42,512.57a12.78,12.78 0,0 1,9.16 -13.94l53.74,-103.17a10.3,10.3 0,1 1,17.52 10.82L525.84,508.73a12.42,12.42 0,0 1,0.2 1.89,12.86 12.86,0 0,1 -13.03,13.21h0a12.87,12.87 0,0 1,-9.87 -4.83,12.71 12.71,0 0,1 -2.71,-6.43Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M556.81,322.35h44.36L601.16,303.02c-9.74,-3.87 -19.26,-7.16 -25.02,0a19.34,19.34 0,0 0,-19.34 19.34Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M603.62,299.61c26.52,0 33.94,33.24 33.94,51.99 0,10.46 -4.73,14.2 -12.16,15.46l-2.63,-14 -6.15,14.6c-2.09,0.01 -4.28,-0.03 -6.55,-0.07l-2.08,-4.29 -4.65,4.22c-18.62,0.03 -33.66,2.74 -33.66,-15.92C569.68,332.85 576.19,299.61 603.62,299.61Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M595.72,327L595.72,301.13a2.33,2.33 0,0 0,-2.33 -2.33h-4.67a2.33,2.33 0,0 0,-2.33 2.33L586.39,325.44a14.74,14.74 0,1 0,9.33 1.56Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M589.5,340.01m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0"
android:fillColor="#fff"/>
</vector>

View file

@ -382,11 +382,23 @@
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_queue" />
<ImageButton
android:id="@+id/player_open_equalizer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_eq" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/eq_frame_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/eq_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:padding="16dp">
<LinearLayout
android:id="@+id/eq_root_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/equalizer_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_fragment_title"
style="@style/HeadlineSmall"
android:layout_gravity="center_horizontal"
android:paddingBottom="16dp" />
<LinearLayout
android:id="@+id/equalizer_switch_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingBottom="16dp">
<TextView
android:id="@+id/equalizer_switch_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
style="@style/LabelMedium"
android:text="@string/equalizer_enable" />
<Switch
android:id="@+id/equalizer_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/eq_bands_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
</LinearLayout>
<Button
android:id="@+id/equalizer_reset_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_reset"
android:layout_gravity="center_horizontal"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="24dp"/>
<Space
android:id="@+id/equalizer_bottom_space"
android:layout_width="match_parent"
android:layout_height="128dp"
android:layout_marginTop="0dp" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:id="@+id/equalizer_not_supported_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_gravity="center"
android:visibility="gone">
<ImageView
android:id="@+id/equalizer_not_supported_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:maxWidth="240dp"
android:maxHeight="240dp"
android:scaleType="centerInside"
android:src="@drawable/ui_eq_not_supported" />
<TextView
android:id="@+id/equalizer_not_supported_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_not_supported"
android:gravity="center"
style="@style/BodyMedium"
android:layout_marginTop="16dp"/>
</LinearLayout>
</FrameLayout>

View file

@ -381,11 +381,23 @@
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_queue" />
<ImageButton
android:id="@+id/player_open_equalizer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_eq" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -55,6 +55,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<View
android:id="@+id/cover_art_overlay"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="16dp"
android:background="#80000000"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/song_cover_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<ImageView
android:id="@+id/play_pause_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:layout_marginStart="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<TextView
android:id="@+id/track_number_text_view"
style="@style/LabelLarge"

View file

@ -20,6 +20,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_art_overlay"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="2dp"
android:background="#80000000"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/play_pause_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:layout_margin="14dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/queue_song_title_text_view"
style="@style/LabelMedium"

View file

@ -151,6 +151,9 @@
app:destination="@id/loginFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_settingsFragment_to_equalizerFragment"
app:destination="@id/equalizerFragment" />
</fragment>
<fragment
android:id="@+id/searchFragment"
@ -300,6 +303,20 @@
android:id="@+id/action_indexFragment_to_directoryFragment"
app:destination="@id/directoryFragment" />
</fragment>
<fragment
android:id="@+id/playerControllerFragment"
android:name="com.cappielloantonio.tempo.ui.fragment.PlayerControllerFragment"
android:label="PlayerControllerFragment"
tools:layout="@layout/inner_fragment_player_controller">
<action
android:id="@+id/action_playerControllerFragment_to_equalizerFragment"
app:destination="@id/equalizerFragment"/>
</fragment>
<fragment
android:id="@+id/equalizerFragment"
android:name="com.cappielloantonio.tempo.ui.fragment.EqualizerFragment"
android:label="EqualizerFragment"
tools:layout="@layout/fragment_equalizer" />
<dialog
android:id="@+id/songBottomSheetDialog"
android:name="com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog"

View file

@ -282,8 +282,8 @@
<string name="settings_delete_download_storage_summary">Wenn Du weitermachst werden alle gespeicherten Inhalte unwiderruflich gelöscht.</string>
<string name="settings_delete_download_storage_title">Gespeicherte Inhalte löschen</string>
<string name="settings_download_storage_title">Download storage</string>
<string name="settings_equalizer_summary">Audio Einstellungen anpassen</string>
<string name="settings_equalizer_title">Equalizer</string>
<string name="settings_system_equalizer_summary">Audio Einstellungen anpassen</string>
<string name="settings_system_equalizer_title">System-Equalizer</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Verfolge die Entwicklung</string>
<string name="settings_github_title">Github</string>

View file

@ -257,8 +257,8 @@
<string name="search_hint">Buscar pista, artistas o álbumes</string>
<string name="search_info_minimum_characters">Introduzca al menos tres caracteres</string>
<string name="search_title_album">Álbumes</string>
<string name="settings_equalizer_summary">Ajustes de audio</string>
<string name="settings_equalizer_title">Ecualizador</string>
<string name="settings_system_equalizer_summary">Ajustes de audio</string>
<string name="settings_system_equalizer_title">Ecualizador del sistema</string>
<string name="search_title_artist">Artistas</string>
<string name="search_title_song">Pistas</string>
<string name="server_signup_dialog_action_low_security">Baja seguridad</string>
@ -435,4 +435,10 @@
<string name="home_sync_starred_albums_title">Sincronizar álbumes favoritos</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_album_sync_dialog_summary">Descargar los álbumes favoritos puede consumir una gran cantidad de datos.</string>
<string name="equalizer_fragment_title">Ecualizador</string>
<string name="equalizer_reset">Restablecer</string>
<string name="equalizer_enable">Habilitar</string>
<string name="equalizer_not_supported">No disponible en este dispositivo</string>
<string name="settings_app_equalizer">Ecualizador</string>
<string name="settings_app_equalizer_summary">Abrir el ecualizador integrado</string>
</resources>

View file

@ -117,6 +117,8 @@
<string name="home_sync_starred_download">Télécharger</string>
<string name="home_sync_starred_subtitle">Télécharger ces titres peut entraîner une utilisation importante de données</string>
<string name="home_sync_starred_title">On dirait qu\'il y a des titres favoris à synchroniser</string>
<string name="home_sync_starred_albums_title">Synchroniser les albums favoris</string>
<string name="home_sync_starred_albums_subtitle">Les albums marqués d\'une étoile seront disponibles hors-ligne</string>
<string name="home_title_best_of">Best of</string>
<string name="home_title_discovery">Découverte</string>
<string name="home_title_discovery_shuffle_all_button">Tout mélanger</string>
@ -294,8 +296,8 @@
<string name="settings_delete_download_storage_summary">Continuer entraînera la suppression irréversible de tous les éléments sauvegardés.</string>
<string name="settings_delete_download_storage_title">Supprimer les éléments sauvegardés</string>
<string name="settings_download_storage_title">Stockage des téléchargements</string>
<string name="settings_equalizer_summary">Ajuster les paramètres audios</string>
<string name="settings_equalizer_title">Égaliseur</string>
<string name="settings_system_equalizer_summary">Ajuster les paramètres audios</string>
<string name="settings_system_equalizer_title">Égaliseur du système</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Suivre le développement</string>
<string name="settings_github_title">Github</string>
@ -341,7 +343,9 @@
<string name="settings_summary_streaming_cache_size">%1$s \nUtilisé actuellement : %2$s MiB</string>
<string name="settings_summary_transcoding">Le mode de transcodage à prioriser. Si réglé sur \"Lecture directe\", le débit binaire du fichier ne sera pas modifié.</string>
<string name="settings_summary_transcoding_download">Télécharge les médias transcodés. Si activé, les paramètres de transcodage suivants seront utilisés pour les téléchargements.\n\n Si le format de transcodage est reglé à \"Téléchargement direct\", le débit binaire du fichier ne sera pas modifé.</string>
<string name="settings_summary_transcoding_estimate_content_length">Quand le fichier est transcodé à la volé, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.</string>
<string name="settings_summary_transcoding_estimate_content_length">Quand le fichier est transcodé à la volée, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Si activé, les albums favoris seront téléchargés pour l\'écoute hors-ligne</string>
<string name="settings_sync_starred_albums_for_offline_use_title">Synchronisation des albums favoris pour écoute hors-ligne</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligne</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Synchronisation des pistes favorites pour écoute hors-ligne</string>
<string name="settings_theme">Thème</string>
@ -394,8 +398,10 @@
<string name="starred_sync_dialog_negative_button">Annuler</string>
<string name="starred_sync_dialog_neutral_button">Continuer</string>
<string name="starred_sync_dialog_positive_button">Continuer et télécharger</string>
<string name="starred_sync_dialog_summary">Le téléchargement des titres favoris pourrer utiliser beaucoup de données.</string>
<string name="starred_sync_dialog_summary">Le téléchargement des titres favoris pourrait consommer beaucoup de données.</string>
<string name="starred_sync_dialog_title">Synchroniser les titres favoris</string>
<string name="starred_album_sync_dialog_summary">Le téléchargement des titres favoris pourrait consommer beaucoup de données.</string>
<string name="starred_album_sync_dialog_title">Synchroniser les albums favoris</string>
<string name="streaming_cache_storage_dialog_sub_summary">Veuillez redémarrer l\'app pour appliquer les changements.</string>
<string name="streaming_cache_storage_dialog_summary">Modifier le chemin de stockage des fichiers mis en cache risque de provoquer la suppression de tous les fichiers précédemment mis en cache dans le nouvel espace de stockage.</string>
<string name="streaming_cache_storage_dialog_title">Sélectionner une option de stockage</string>
@ -430,4 +436,8 @@
<string name="undraw_page">unDraw</string>
<string name="undraw_thanks">Un grand merci à unDraw, nous n\'aurions pas pu rendre cette application aussi belle sans leurs illustrations.</string>
<string name="undraw_url">https://undraw.co/</string>
<plurals name="home_sync_starred_albums_count">
<item quantity="one">%d album à synchroniser</item>
<item quantity="other">%d albums à synchroniser</item>
</plurals>
</resources>

View file

@ -282,8 +282,8 @@
<string name="settings_delete_download_storage_summary">Continuando, tutti gli elementi salvati verranno eliminati in modo irreversibile.</string>
<string name="settings_delete_download_storage_title">Elimina elementi salvati</string>
<string name="settings_download_storage_title">Archivio download</string>
<string name="settings_equalizer_summary">Regola le impostazioni audio</string>
<string name="settings_equalizer_title">Equalizzatore</string>
<string name="settings_system_equalizer_summary">Regola le impostazioni audio</string>
<string name="settings_system_equalizer_title">Equalizzatore di sistema</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Segui lo sviluppo</string>
<string name="settings_github_title">Github</string>

View file

@ -24,7 +24,9 @@
<string name="album_list_page_title">앨범</string>
<string name="album_page_extra_info_button">유사항목 더 보기</string>
<string name="album_page_play_button">재생</string>
<string name="album_page_release_dates_label">%1$s에 발매, %2$s에 최초 발매됨</string>
<string name="album_page_shuffle_button">셔플</string>
<string name="album_page_tracks_count_and_duration">%1$d 곡 • %2$d 분</string>
<string name="app_name">Tempo</string>
<string name="artist_adapter_radio_station_starting">탐색 중…</string>
<string name="artist_bottom_sheet_instant_mix">인스턴트 믹스</string>
@ -53,11 +55,14 @@
<string name="connection_alert_dialog_positive_button">OK</string>
<string name="connection_alert_dialog_summary">Wi-Fi가 연결되지 않은 상태에서 Subsonic 서버에 대한 액세스가 제한되었습니다. 이 경고를 다시 보지 않으려면 앱 설정에서 연결 확인을 비활성화 해주세요.</string>
<string name="connection_alert_dialog_title">Wi-Fi가 연결되지 않음</string>
<string name="content_description_shuffle_button">셔플</string>
<string name="delete_download_storage_dialog_negative_button">취소</string>
<string name="delete_download_storage_dialog_positive_button">계속</string>
<string name="delete_download_storage_dialog_summary">계속할 시 서버에서 다운로드한 모든 저장 항목이 영구적으로 삭제됩니다.</string>
<string name="delete_download_storage_dialog_title">저장된 항목 삭제</string>
<string name="description_empty_title">설명 란이 비어있습니다.</string>
<string name="disc_titlefull">디스크 %1$s - %2$s</string>
<string name="disc_titleless">디스크 %1$s</string>
<string name="download_directory_dialog_negative_button">취소</string>
<string name="download_directory_dialog_positive_button">다운로드</string>
<string name="download_directory_dialog_summary">하위 폴더를 제외한 해당 폴더의 모든 트랙이 다운로드됩니다.</string>
@ -66,9 +71,10 @@
<string name="download_info_empty_title">다운로드 하지 않음</string>
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s 항목</string>
<string name="download_item_single_subtitle_formatter">%1$s 항목</string>
<string name="download_shuffle_all_subtitle">모두 셔플</string>
<string name="download_storage_dialog_sub_summary">변경 사항을 저장하려면 앱을 다시 시작하세요.</string>
<string name="download_storage_dialog_summary">>다운로드한 파일을 다른 저장소로 변경하면 기존 저장소에서 다운로드한 파일은 즉시 삭제됩니다.</string>
<string name="download_storage_dialog_title">저장소 선택 옵션</string>
<string name="download_storage_dialog_summary">다운로드한 파일을 다른 저장소로 변경하면 기존 저장소에서 다운로드한 파일은 즉시 삭제됩니다.</string>
<string name="download_storage_dialog_title">저장소 옵션 선택</string>
<string name="download_storage_external_dialog_positive_button">외부</string>
<string name="download_storage_internal_dialog_negative_button">내부</string>
<string name="download_title_section">다운로드</string>
@ -83,9 +89,11 @@
<string name="exo_download_notification_channel_name">다운로드</string>
<string name="filter_info_selection">둘 이상의 필터를 선택해 주세요.</string>
<string name="filter_title">필터</string>
<string name="filter_artist">아티스트 필터링</string>
<string name="filter_title_expanded">장르 필터링</string>
<string name="genre_catalogue_title">장르 카탈로그</string>
<string name="genre_catalogue_title_expanded">장르 찾아보기</string>
<string name="home_section_radio">라디오</string>
<string name="home_subtitle_best_of">최애 아티스트의 인기곡</string>
<string name="home_subtitle_made_for_you">좋아하는 음악으로 믹스를 시작해 보세요.</string>
<string name="home_subtitle_new_internet_radio_station">새 라디오 추가</string>
@ -102,11 +110,13 @@
<string name="home_title_last_played">최근 재생</string>
<string name="home_title_last_played_see_all_button">모두 보기</string>
<string name="home_title_last_week">지난 주</string>
<string name="home_title_last_year">지난 해</string>
<string name="home_title_made_for_you">Made for you</string>
<string name="home_title_most_played">가장 많이 재생</string>
<string name="home_title_most_played_see_all_button">모두 보기</string>
<string name="home_title_new_releases">New releases</string>
<string name="home_title_newest_podcasts">새로운 팟캐스트</string>
<string name="home_title_pinned_playlists">재생목록</string>
<string name="home_title_podcast_channels">채널</string>
<string name="home_title_podcast_channels_see_all_button">모두 보기</string>
<string name="home_title_radio_station">라디오 스테이션</string>
@ -120,8 +130,7 @@
<string name="home_title_starred_tracks">★ 즐겨찾기한 트랙</string>
<string name="home_title_starred_tracks_see_all_button">모두 보기</string>
<string name="home_title_top_songs">자주 플레이한 음악</string>
<string name="label_dot_separator" translatable="false"></string>
<string name="label_placeholder" translatable="false">--</string>
<string name="home_option_reorganize">다시 정렬</string>
<string name="library_title_album">앨범</string>
<string name="library_title_album_see_all_button">모두 보기</string>
<string name="library_title_artist">아티스트</string>
@ -138,6 +147,7 @@
<string name="menu_add_button">추가</string>
<string name="menu_add_to_playlist_button">플레이리스트에 추가</string>
<string name="menu_download_all_button">모두 다운로드</string>
<string name="menu_rate_album">앨범 평점 매기기</string>
<string name="menu_download_label">다운로드</string>
<string name="menu_filter_all">모두</string>
<string name="menu_filter_download">다운로드한</string>
@ -146,13 +156,15 @@
<string name="menu_group_by_genre">장르</string>
<string name="menu_group_by_track">트랙</string>
<string name="menu_group_by_year">년도</string>
<string name="menu_home_label">홈으로</string>
<string name="menu_home_label"></string>
<string name="menu_last_year_name">지난 해</string>
<string name="menu_library_label">라이브러리</string>
<string name="menu_search_button">검색</string>
<string name="menu_settings_button">셋팅</string>
<string name="menu_settings_button">설정</string>
<string name="menu_sort_artist">아티스트</string>
<string name="menu_sort_name">이름</string>
<string name="menu_sort_random">랜덤</string>
<string name="menu_unpin_button">홈화면에서 제거</string>
<string name="menu_sort_year">년도</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">재생목록 비우기</string>
@ -163,10 +175,11 @@
<string name="playlist_chooser_dialog_negative_button">취소</string>
<string name="playlist_chooser_dialog_neutral_button">생성</string>
<string name="playlist_chooser_dialog_title">플레이리스트 추가</string>
<string name="playlist_chooser_dialog_toast_add_success">재생 목록에 노래 추가</string>
<string name="playlist_chooser_dialog_toast_add_failure">재생 목록에 노래를 추가하지 못했습니다.</string>
<string name="playlist_chooser_dialog_toast_add_success">재생 목록에 음악 추가</string>
<string name="playlist_chooser_dialog_toast_add_failure">재생 목록에 음악을 추가하지 못했습니다.</string>
<string name="playlist_counted_tracks">%1$d 트랙 • %2$s</string>
<string name="playlist_duration">재생시간 • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">길게 눌러 삭제하기</string>
<string name="playlist_editor_dialog_hint_name">플레이리스트 이름</string>
<string name="playlist_editor_dialog_negative_button">취소</string>
<string name="playlist_editor_dialog_neutral_button">삭제</string>
@ -248,8 +261,8 @@
<string name="settings_delete_download_storage_summary">계속하면 저장된 모든 항목을 완전히 삭제합니다.</string>
<string name="settings_delete_download_storage_title">저장된 항목 삭제</string>
<string name="settings_download_storage_title">스토리지 다운로드</string>
<string name="settings_equalizer_summary">오디오 설정 적용</string>
<string name="settings_equalizer_title">이퀄라이저</string>
<string name="settings_system_equalizer_summary">오디오 설정 적용</string>
<string name="settings_system_equalizer_title">시스템 이퀄라이저</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Follow the development</string>
<string name="settings_github_title">Github</string>
@ -264,6 +277,7 @@
<string name="settings_music_directory_summary">활성화 시, 음악 디렉터리 섹션을 표시합니다. 폴더 탐색이 제대로 작동하려면 서버가 이 기능을 지원해야 합니다.</string>
<string name="settings_podcast">팟캐스트 보기</string>
<string name="settings_podcast_summary">활성화 시, 팟캐스트 섹션을 표시합니다.</string>
<string name="settings_item_rating_summary">활성화 시 평점과 즐겨찾기 여부가 표시됩니다</string>
<string name="settings_queue_syncing_countdown">동기화 타이머</string>
<string name="settings_queue_syncing_summary">활성화 시, 재생목록을 저장하여 재실행 시 상태를 불러올 수 있습니다.</string>
<string name="settings_queue_syncing_title">사용자의 재생목록 동기화</string>
@ -276,16 +290,21 @@
<string name="settings_rounded_corner_summary">활성화 시, 렌더링된 모든 앨범 커버의 곡률 각도를 설정합니다. 다시 시작하면 적용됩니다.</string>
<string name="settings_scan_title">라이브러리 스캔</string>
<string name="settings_scrobble_title">음악 스크로블링 활성화</string>
<string name="settings_system_language">시스템 언어</string>
<string name="settings_share_title">음악 공유 활성화</string>
<string name="settings_streaming_cache_storage_title">스트리밍 캐시 저장공간</string>
<string name="settings_sub_summary_scrobble">스크로블링은 이 데이터를 수신할 수 있는 서버에 의존합니다.</string>
<string name="settings_summary_skip_min_star_rating">아티스트의 라디오를 들을 때, 인스턴트 믹스를 들을 때, 전체를 셔플할 때 특정 별점 이하의 트랙은 무시됩니다.</string>
<string name="settings_summary_replay_gain">Replay gain은 일관된 청취 경험을 위해 오디오 트랙의 볼륨 레벨을 조정할 수 있는 기능입니다. 이 설정은 필요한 메타데이터가 트랙에 포함된 경우에만 유효합니다.</string>
<string name="settings_summary_scrobble">스크로블링은 기기에서 들은 음악 정보를 음악 서버로 보내는 기능입니다. 이 정보는 음악 선호도에 따른 맞춤 추천을 생성하는 데 사용합니다.</string>
<string name="settings_summary_share">링크를 통해 음악을 공유할 수 있습니다. 이 기능은 서버 측에서 지원 및 활성화되어야 하며 개별 트랙, 앨범, 재생 목록으로 제한됩니다.</string>
<string name="settings_summary_syncing">사용자의 재생목록의 상태를 반환합니다. 재생목록의 트랙, 현재 재생 중인 트랙, 트랙 번호가 포함됩니다. 서버가 이 기능을 지원해야 합니다.</string>
<string name="settings_summary_streaming_cache_size">%1$s \n사용 중: %2$s MiB </string>
<string name="settings_summary_transcoding">트랜스코딩 모드에 우선순위가 부여됩니다. \"직접 재생\"으로 설정하면 파일의 비트 전송률이 변경되지 않습니다.</string>
<string name="settings_summary_transcoding_download">트랜스코딩된 미디어를 다운로드합니다. 활성화하면 다운로드 endpoint를 사용하지 않고 다음 설정이 사용됩니다. \n\n \"다운로드용 트랜스코딩 포맷\"이 \"직접 다운로드\"로 설정된 경우 파일의 비트 전송률은 변경되지 않습니다.</string>
<string name="settings_summary_transcoding_estimate_content_length">파일이 즉시 트랜스코딩되면 일반적으로 트랙 길이를 표시하지 않습니다. 트랙의 재생시간을 추정하는 기능을 지원한다면 서버에 요청할 수 있지만 응답 시간이 필요할 수 있습니다.</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">활성화 시, 즐겨찾기 앨범을 오프라인으로 사용할 수 있도록 다운로드합니다.</string>
<string name="settings_sync_starred_albums_for_offline_use_title">오프라인 사용을 위해 즐겨찾기 앨범 동기화</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">활성화 시, 즐겨찾기 트랙을 오프라인으로 사용할 수 있도록 다운로드합니다.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">오프라인 사용을 위해 즐겨찾기 트랙 동기화</string>
<string name="settings_theme">테마</string>
@ -302,7 +321,6 @@
<string name="settings_title_transcoding_download">트랜스코딩 다운로드</string>
<string name="settings_title_ui">UI</string>
<string name="settings_transcoded_download">트랜스코딩된 다운로드</string>
<string name="settings_version_summary" translatable="false">3.1.0</string>
<string name="settings_version_title">버전</string>
<string name="settings_wifi_only_summary">모바일 데이터로 스트리밍하려 할 시 확인창을 띄웁니다.</string>
<string name="settings_wifi_only_title">Wi-Fi로만 스트리밍 확인창</string>
@ -351,6 +369,7 @@
<string name="track_info_duration">재생 시간</string>
<string name="track_info_genre">장르</string>
<string name="track_info_path">경로</string>
<string name="track_info_sampling_rate">샘플링 레이트</string>
<string name="track_info_size">크기</string>
<string name="track_info_suffix">접미사</string>
<string name="track_info_summary_downloaded_file">파일은 Subsonic API를 사용하여 다운로드되었습니다. 파일의 코덱과 비트 전송률은 소스 파일과 동일하게 유지됩니다.</string>
@ -367,4 +386,42 @@
<string name="undraw_page">unDraw</string>
<string name="undraw_thanks">이 앱을 일러스트로 더 다채롭게 꾸밀 수 있도록 해준 unDraw 에 특별히 감사드립니다.</string>
<string name="undraw_url">https://undraw.co/</string>
<string name="home_rearrangement_dialog_subtitle">이 작업은 시간이 소요되며, 다시 시작 후 적용됩니다</string>
<string name="home_section_music">음악</string>
<string name="home_section_podcast">팟캐스트</string>
<string name="home_title_last_month">지난 달</string>
<string name="menu_last_week_name">지난 주</string>
<string name="menu_last_month_name">지난 달</string>
<string name="menu_sort_recently_added">최근에 추가됨</string>
<string name="menu_sort_recently_played">최근에 재생됨</string>
<string name="menu_sort_most_played">많이 재생됨</string>
<string name="menu_sort_least_recently_starred">즐겨찾기 오래된순</string>
<string name="menu_pin_button">홈화면에 추가</string>
<string name="album_page_release_date_label">%1$s에 발매됨</string>
<string name="settings_audio_quality">오디오 품질 표시하기</string>
<string name="settings_audio_quality_summary">오디오 트랙에 비트레이트와 포맷이 표시됩니다.</string>
<string name="settings_song_rating">음악 별점 표시하기</string>
<string name="settings_streaming_cache_size">스트리밍 캐시 크기</string>
<string name="streaming_cache_storage_dialog_sub_summary">변경 사항을 저장하려면 앱을 다시 시작하세요.</string>
<string name="streaming_cache_storage_dialog_summary">캐시 파일을 다른 저장소로 변경하면 기존의 캐시 파일이 삭제될 수 있습니다.</string>
<string name="streaming_cache_storage_dialog_title">저장소 옵션 선택</string>
<string name="streaming_cache_storage_external_dialog_positive_button">외부</string>
<string name="streaming_cache_storage_internal_dialog_negative_button">내부</string>
<string name="github_update_dialog_negative_button">나중에 다시 알려주기</string>
<string name="github_update_dialog_positive_button">지금 다운로드 받기</string>
<string name="github_update_dialog_summary">Github에 최신 버전이 존재합니다</string>
<string name="github_update_dialog_title">업데이트 가능</string>
<string name="home_rearrangement_dialog_negative_button">취소</string>
<string name="home_rearrangement_dialog_neutral_button">초기화</string>
<string name="home_rearrangement_dialog_positive_button">저장</string>
<string name="home_rearrangement_dialog_title">홈 다시 정렬</string>
<string name="home_sync_starred_albums_title">즐겨찾기한 앨범 동기화</string>
<string name="menu_sort_most_recently_starred">즐겨찾기 최신순</string>
<string name="player_unknown_format">즐겨찾기 오래된순</string>
<string name="player_transcoding">트랜스코딩</string>
<string name="server_signup_dialog_action_delete_toast">길게 눌러 삭제하기</string>
<string name="settings_song_rating_summary">활성화 시, 별점이 음악 페이지에서 숨겨집니다 \n*다시 시작이 필요합니다</string>
<string name="starred_album_sync_dialog_summary">즐겨찾기한 앨범을 다운로드할 시 많은 양의 데이터가 필요할 수 있습니다.</string>
<string name="starred_album_sync_dialog_title">즐겨찾기 한 앨범 동기화</string>
<string name="settings_item_rating">평점 표시하기</string>
</resources>

View file

@ -107,6 +107,7 @@
<string name="home_section_music">Muzyka</string>
<string name="home_section_podcast">Podcasty</string>
<string name="home_section_radio">Radio</string>
<string name="player_queue_save_queue_success">Zapisano kolejkę odtwarzania</string>
<string name="track_info_bit_depth">Głębia bitowa</string>
<string name="track_info_sampling_rate">Częstotliwość próbkowania</string>
<string name="settings_system_language">Język systemu</string>
@ -289,8 +290,8 @@
<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_equalizer_summary">Zmień ustawienia audio</string>
<string name="settings_equalizer_title">Equalizer</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>
<string name="settings_github_summary">Śledź tworzenie aplikacji</string>
<string name="settings_github_title">GitHub</string>
@ -421,4 +422,14 @@
<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>
<plurals name="home_sync_starred_albums_count">
<item quantity="one">%d album do zsynchronizowania </item>
<item quantity="other">%d albumów do zsynchrpnizowania</item>
</plurals>
<string name="equalizer_fragment_title">Korektor dźwięku</string>
<string name="equalizer_reset">Reset</string>
<string name="equalizer_enable">Włączony</string>
<string name="equalizer_not_supported">Nie wspierane na tym urządzeniu</string>
<string name="settings_app_equalizer">Korektor dźwięku</string>
<string name="settings_app_equalizer_summary">Otwórz wbudowany korektor dźwięku</string>
</resources>

View file

@ -248,8 +248,8 @@
<string name="settings_delete_download_storage_summary">O processo resultará na exclusão irreversível de todos os itens salvos.</string>
<string name="settings_delete_download_storage_title">Excluir itens salvos</string>
<string name="settings_download_storage_title">Armazenamento dos downloads</string>
<string name="settings_equalizer_summary">Ajustar configurações de áudio</string>
<string name="settings_equalizer_title">Equalizador</string>
<string name="settings_system_equalizer_summary">Ajustar configurações de áudio</string>
<string name="settings_system_equalizer_title">Equalizador do sistema</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Acompanhe o desenvolvimento</string>
<string name="settings_github_title">Github</string>

View file

@ -2,7 +2,7 @@
<string name="activity_battery_optimizations_conclusion">Если у вас возникли проблемы, посетите https://dontkillmyapp.com. Он содержит подробные инструкции о том, как отключить любые функции энергосбережения, которые могут повлиять на производительность приложения.</string>
<string name="activity_battery_optimizations_summary">Пожалуйста, отключите оптимизацию батареи для воспроизведения мультимедиа при выключенном экране.</string>
<string name="activity_battery_optimizations_title">Оптимизация батареи</string>
<string name="activity_info_offline_mode">Офлайн-режим</string>
<string name="activity_info_offline_mode">Автономный режим</string>
<string name="album_bottom_sheet_add_to_playlist">Добавить в плейлист</string>
<string name="album_bottom_sheet_add_to_queue">Добавить в очередь</string>
<string name="album_bottom_sheet_download_all">Скачать все</string>
@ -90,6 +90,7 @@
<string name="exo_download_notification_channel_name">Загрузки</string>
<string name="filter_info_selection">Выберите два или более фильтров</string>
<string name="filter_title">Фильтр</string>
<string name="filter_artist">Фильтровать исполнителей</string>
<string name="filter_title_expanded">Фильтровать жанры</string>
<string name="genre_catalogue_title">Каталог жанров</string>
<string name="genre_catalogue_title_expanded">Просмотр жанров</string>
@ -103,6 +104,9 @@
<string name="home_rearrangement_dialog_positive_button">Сохранять</string>
<string name="home_rearrangement_dialog_title">Настроить главную</string>
<string name="home_rearrangement_dialog_subtitle">Обратите внимание, чтобы внесенные изменения вступили в силу, необходимо перезапустить приложение.</string>
<string name="home_section_music">Музыка</string>
<string name="home_section_podcast">Подкасты</string>
<string name="home_section_radio">Радио</string>
<string name="home_subtitle_best_of">Лучшие треки любимых исполнителей</string>
<string name="home_subtitle_made_for_you">Запустите микс с понравившимся вам треком</string>
<string name="home_subtitle_new_internet_radio_station">Добавить новое радио</string>
@ -111,6 +115,8 @@
<string name="home_sync_starred_download">Скачать</string>
<string name="home_sync_starred_subtitle">Загрузка этих треков может потребовать значительного использования данных</string>
<string name="home_sync_starred_title">Похоже, есть несколько отмеченных треков для синхронизации.</string>
<string name="home_sync_starred_albums_title">Синхронизировать отмеченные альбомы</string>
<string name="home_sync_starred_albums_subtitle">Отмеченные альбомы будут доступны в автономном режиме</string>
<string name="home_title_best_of">Лучшее из</string>
<string name="home_title_discovery">Открытие</string>
<string name="home_title_discovery_shuffle_all_button">Перемешать все</string>
@ -126,6 +132,7 @@
<string name="home_title_most_played_see_all_button">Увидеть все</string>
<string name="home_title_new_releases">Новые релизы</string>
<string name="home_title_newest_podcasts">Новейшие подкасты</string>
<string name="home_title_pinned_playlists">Плейлисты</string>
<string name="home_title_podcast_channels">Каналы</string>
<string name="home_title_podcast_channels_see_all_button">Увидеть все</string>
<string name="home_title_radio_station">Радиостанции</string>
@ -156,6 +163,7 @@
<string name="menu_add_button">Добавить</string>
<string name="menu_add_to_playlist_button">Добавить в плейлист</string>
<string name="menu_download_all_button">Скачать все</string>
<string name="menu_rate_album">Оценить альбом</string>
<string name="menu_download_label">Скачать</string>
<string name="menu_filter_all">Все</string>
<string name="menu_filter_download">Загружено</string>
@ -165,6 +173,9 @@
<string name="menu_group_by_track">Трек</string>
<string name="menu_group_by_year">Год</string>
<string name="menu_home_label">Главная</string>
<string name="menu_last_week_name">Прошлая неделя</string>
<string name="menu_last_month_name">Прошлый месяц</string>
<string name="menu_last_year_name">Прошлый год</string>
<string name="menu_library_label">Библиотека</string>
<string name="menu_search_button">Поиск</string>
<string name="menu_settings_button">Настройки</string>
@ -181,7 +192,11 @@
<string name="menu_unpin_button">Убрать с главного экрана</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Очистить очередь воспроизведения</string>
<string name="player_queue_save_queue_success">Очередь сохранена</string>
<string name="player_server_priority">Приоритет сервера</string>
<string name="player_unknown_format">Неизвестный форма</string>
<string name="player_transcoding">Транскодирование</string>
<string name="player_transcoding_requested">запрошено</string>
<string name="playlist_catalogue_title">Каталог плейлистов</string>
<string name="playlist_catalogue_title_expanded">Просмотр плейлистов</string>
<string name="playlist_chooser_dialog_empty">Плейлисты не созданы</string>
@ -271,14 +286,16 @@
<string name="settings_audio_transcode_priority_toast">Приоритет при перекодировании трека отдается серверу</string>
<string name="settings_buffering_strategy">Стратегия буферизации</string>
<string name="settings_buffering_strategy_summary">Чтобы изменения вступили в силу, необходимо вручную перезапустить приложение.</string>
<string name="settings_continuous_play_summary">Разрешить играть включать треки после окончания плейлиста</string>
<string name="settings_continuous_play_title">Продолжать играть</string>
<string name="settings_covers_cache">Размер кэша обложек</string>
<string name="settings_data_saving_mode_summary">Чтобы сократить потребление данных, избегайте загрузки обложек.</string>
<string name="settings_data_saving_mode_title">Ограничить использование мобильных данных</string>
<string name="settings_delete_download_storage_summary">Продолжение приведет к необратимому удалению всех сохраненных элементов.</string>
<string name="settings_delete_download_storage_title">Удалить сохраненные элементы</string>
<string name="settings_download_storage_title">Загрузить хранилище</string>
<string name="settings_equalizer_summary">Отрегулируйте настройки звука</string>
<string name="settings_equalizer_title">Эквалайзер</string>
<string name="settings_system_equalizer_summary">Отрегулируйте настройки звука</string>
<string name="settings_system_equalizer_title">Системный эквалайзер</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Следите за развитием</string>
<string name="settings_github_title">Github</string>
@ -295,6 +312,8 @@
<string name="settings_podcast_summary">Если включено, показывать раздел подкаста. Перезапустите приложение, чтобы оно вступило в силу.</string>
<string name="settings_audio_quality">Показать качество звука (битрейт)</string>
<string name="settings_audio_quality_summary">Битрейт и аудиоформат будут показаны для каждой аудиодорожки.</string>
<string name="settings_song_rating">Показать рейтинг трека</string>
<string name="settings_song_rating_summary">Если эта функция включена, будет отображаться пятизвездочный рейтинг трека на странице воспроизведения\n\n*Требует перезапуска приложения</string>
<string name="settings_item_rating">Показать рейтинг</string>
<string name="settings_item_rating_summary">Если эта функция включена, будет отображаться рейтинг элемента и то, отмечен ли он как избранный.</string>
<string name="settings_queue_syncing_countdown">Таймер синхронизации</string>
@ -309,18 +328,24 @@
<string name="settings_rounded_corner_summary">Если этот параметр включен, задает угол кривизны для всех отображаемых обложек. Изменения вступят в силу при перезапуске.</string>
<string name="settings_scan_title">Сканировать библиотеку</string>
<string name="settings_scrobble_title">Включить скробблинг музыки Last.FM и т.д.</string>
<string name="settings_system_language">Язык системы</string>
<string name="settings_share_title">Включить обмен музыкой</string>
<string name="settings_streaming_cache_size">Размер кэша стриминга</string>
<string name="settings_streaming_cache_storage_title">Хранилище кэша стриминга</string>
<string name="settings_sub_summary_scrobble">Важно отметить, что скробблинг также зависит от того, настроен ли сервер для получения этих данных.</string>
<string name="settings_summary_skip_min_star_rating">При прослушивании радио исполнителя, мгновенном миксе или перемешивании всех, треки ниже определенного пользовательского рейтинга будут игнорироваться.</string>
<string name="settings_summary_replay_gain">Усиление воспроизведения — это функция, которая позволяет регулировать уровень громкости звуковых дорожек для обеспечения единообразного качества прослушивания. Этот параметр действует только в том случае, если трек содержит необходимые метаданные.</string>
<string name="settings_summary_scrobble">Скробблинг — это функция, которая позволяет вашему устройству отправлять информацию о песнях, которые вы слушаете, на музыкальный сервер. Эта информация помогает создавать персональные рекомендации на основе ваших музыкальных предпочтений.</string>
<string name="settings_summary_share">Позволяет пользователю делиться музыкой по ссылке. Функциональность должна поддерживаться и включаться на стороне сервера и ограничивается отдельными треками, альбомами и плейлистами.</string>
<string name="settings_summary_syncing">Возвращает состояние очереди воспроизведения для этого пользователя. Сюда входят треки в очереди воспроизведения, воспроизводимый в данный момент трек и позиция внутри этого трека. Сервер должен поддерживать эту функцию.</string>
<string name="settings_summary_streaming_cache_size">%1$s \nСейчас используется: %2$s MiB</string>
<string name="settings_summary_transcoding">Приоритет отдается режиму перекодирования. Если установлено «Прямое воспроизведение», битрейт файла не изменится.</string>
<string name="settings_summary_transcoding_download">Загрузите перекодированные медиафайлы. Если этот параметр включен, будет использоваться не конечная точка загрузки, а следующие настройки. Если для параметра «Формат перекодирования для загрузки» установлено значение «Прямая загрузка», битрейт файла не изменится.</string>
<string name="settings_summary_transcoding_estimate_content_length">Когда файл перекодируется на лету, клиент обычно не показывает длину трека. Можно запросить у серверов, поддерживающих данную функцию, оценку длительности воспроизводимого трека, но время ответа может занять больше времени.</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Если этот параметр включен, помеченные альбомы будут загружены для использования в автономном режиме.</string>
<string name="settings_sync_starred_albums_for_offline_use_title">Синхронизировать помеченные альбомы для использования в автономном режиме.</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Если этот параметр включен, помеченные треки будут загружены для использования в автономном режиме.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Синхронизируйте помеченные треки для использования в автономном режиме.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Синхронизировать помеченные треки для использования в автономном режиме.</string>
<string name="settings_theme">Тема</string>
<string name="settings_title_data">Данные</string>
<string name="settings_title_general">Общий</string>
@ -332,7 +357,7 @@
<string name="settings_title_share">Поделиться</string>
<string name="settings_title_syncing">Синхронизации</string>
<string name="settings_title_transcoding">Транскодирование</string>
<string name="settings_title_transcoding_download">Транскодирование Скачать</string>
<string name="settings_title_transcoding_download">Скачивание с транскодированием</string>
<string name="settings_title_ui">UI (Пользовательский интерфейс)</string>
<string name="settings_transcoded_download">Перекодированная загрузка</string>
<string name="settings_version_title">Версия</string>
@ -373,8 +398,16 @@
<string name="starred_sync_dialog_positive_button">Продолжить и скачать</string>
<string name="starred_sync_dialog_summary">Для скачивания рейтинговых треков может потребоваться большой объем данных.</string>
<string name="starred_sync_dialog_title">Синхронизировать отмеченные треки</string>
<string name="starred_album_sync_dialog_summary">Для скачивания рейтинговых альбомов может потребоваться большой объем данных.</string>
<string name="starred_album_sync_dialog_title">Синхронизировать отмеченные альбомы</string>
<string name="streaming_cache_storage_dialog_sub_summary">Чтобы изменения вступили в силу необходимо перезапустить приложение.</string>
<string name="streaming_cache_storage_dialog_summary">Изменение места сохранения кэшированных файлов с одного на другое может привести к удалению файлов в старом хранилище.</string>
<string name="streaming_cache_storage_dialog_title">Выберите способ сохранения</string>
<string name="streaming_cache_storage_external_dialog_positive_button">Внешний</string>
<string name="streaming_cache_storage_internal_dialog_negative_button">Внутренний</string>
<string name="track_info_album">Альбом</string>
<string name="track_info_artist">Исполнитель</string>
<string name="track_info_bit_depth">Разрядность</string>
<string name="track_info_bitrate">Битрейт</string>
<string name="track_info_content_type">Тип содержимого</string>
<string name="track_info_dialog_positive_button">OK</string>
@ -383,6 +416,7 @@
<string name="track_info_duration">Продолжительность</string>
<string name="track_info_genre">Жанр</string>
<string name="track_info_path">Путь</string>
<string name="track_info_sampling_rate">Частота сэмплирования</string>
<string name="track_info_size">Размер</string>
<string name="track_info_suffix">Суффикс</string>
<string name="track_info_summary_downloaded_file">Файл был загружен с использованием API Subsonic. Кодек и битрейт файла остаются неизменными по сравнению с исходным файлом.</string>
@ -399,4 +433,8 @@
<string name="undraw_page">Развернуть</string>
<string name="undraw_thanks">Особая благодарность — команде unDraw, без иллюстраций которой мы не смогли бы сделать это приложение красивее.</string>
<string name="undraw_url">https://undraw.co/</string>
<plurals name="home_sync_starred_albums_count">
<item quantity="one">Альбомов для синхронизации: %d</item>
<item quantity="other">Альбомов для синхронизации: %d</item>
</plurals>
</resources>

View file

@ -293,8 +293,8 @@
<string name="settings_delete_download_storage_summary">Devam ederseniz tüm kayıtlı öğeler geri alınamaz şekilde silinecektir.</string>
<string name="settings_delete_download_storage_title">Kayıtlı öğeleri sil</string>
<string name="settings_download_storage_title">İndirme depolaması</string>
<string name="settings_equalizer_summary">Ses ayarlarını düzenle</string>
<string name="settings_equalizer_title">Ekolayzır</string>
<string name="settings_system_equalizer_summary">Ses ayarlarını düzenle</string>
<string name="settings_system_equalizer_title">Sistem ekolayzır</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">Gelişmeleri takip et</string>
<string name="settings_github_title">Github</string>

View file

@ -255,8 +255,8 @@
<string name="settings_delete_download_storage_summary">继续当前操作将导致所有已保存的项目被永久删除。</string>
<string name="settings_delete_download_storage_title">删除已保存的项目</string>
<string name="settings_download_storage_title">下载存储</string>
<string name="settings_equalizer_summary">调整音频设置</string>
<string name="settings_equalizer_title">均衡器</string>
<string name="settings_system_equalizer_summary">调整音频设置</string>
<string name="settings_system_equalizer_title">系统均衡器</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string>
<string name="settings_github_summary">关注开发进展</string>
<string name="settings_github_title">Github</string>

View file

@ -298,8 +298,8 @@
<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_equalizer_summary">Adjust audio settings</string>
<string name="settings_equalizer_title">Equalizer</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>
<string name="settings_github_summary">Follow the development</string>
<string name="settings_github_title">Github</string>
@ -443,4 +443,10 @@
<item quantity="one">%d album to sync</item>
<item quantity="other">%d albums to sync</item>
</plurals>
<string name="equalizer_fragment_title">Equalizer</string>
<string name="equalizer_reset">Reset</string>
<string name="equalizer_enable">Enable</string>
<string name="equalizer_not_supported">Not supported on this device</string>
<string name="settings_app_equalizer">Equalizer</string>
<string name="settings_app_equalizer_summary">Open the built-in equalizer</string>
</resources>

View file

@ -2,9 +2,14 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory app:title="@string/settings_title_general">
<Preference
android:key="equalizer"
android:title="@string/settings_equalizer_title"
android:summary="@string/settings_equalizer_summary" />
android:key="system_equalizer"
android:title="@string/settings_system_equalizer_title"
android:summary="@string/settings_system_equalizer_summary" />
<Preference
android:key="app_equalizer"
android:title="@string/settings_app_equalizer"
android:summary="@string/settings_app_equalizer_summary" />
<Preference
android:key="scan_library"

View file

@ -5,12 +5,13 @@ 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.Bundle
import android.os.IBinder
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray
import androidx.media3.session.*
@ -19,6 +20,7 @@ import com.cappielloantonio.tempo.R
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.google.common.collect.ImmutableList
@ -34,9 +36,18 @@ class MediaService : MediaLibraryService() {
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var shuffleCommands: List<CommandButton>
private lateinit var repeatCommands: List<CommandButton>
lateinit var equalizerManager: EqualizerManager
private var customLayout = ImmutableList.of<CommandButton>()
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return this@MediaService.equalizerManager
}
}
private val binder = LocalBinder()
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
"android.media3.session.demo.SHUFFLE_ON"
@ -48,6 +59,7 @@ class MediaService : MediaLibraryService() {
"android.media3.session.demo.REPEAT_ONE"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
"android.media3.session.demo.REPEAT_ALL"
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
}
override fun onCreate() {
@ -57,6 +69,7 @@ class MediaService : MediaLibraryService() {
initializePlayer()
initializeMediaLibrarySession()
initializePlayerListener()
initializeEqualizerManager()
setPlayer(player)
}
@ -66,10 +79,20 @@ class MediaService : MediaLibraryService() {
}
override fun onDestroy() {
equalizerManager.release()
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 inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onConnect(
@ -186,7 +209,7 @@ class MediaService : MediaLibraryService() {
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
@ -197,6 +220,21 @@ class MediaService : MediaLibraryService() {
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 initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
@ -226,7 +264,10 @@ class MediaService : MediaLibraryService() {
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
MediaManager.scrobble(player.currentMediaItem, false)
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)
@ -346,7 +387,4 @@ class MediaService : MediaLibraryService() {
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory() =
DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
}

View file

@ -4,6 +4,8 @@ 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 androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
@ -14,13 +16,13 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
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.google.android.gms.cast.framework.CastContext
@ -34,6 +36,19 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
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"
}
override fun onCreate() {
super.onCreate()
@ -43,10 +58,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
initializeCastPlayer()
initializeMediaLibrarySession()
initializePlayerListener()
initializeEqualizerManager()
setPlayer(
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
)
}
@ -63,10 +79,20 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
}
override fun onDestroy() {
equalizerManager.release()
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()
}
@ -74,7 +100,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
@ -85,9 +111,24 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
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
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) {
castPlayer = CastPlayer(CastContext.getSharedInstance(this))
castPlayer.setSessionAvailabilityListener(this)
@ -96,16 +137,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
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()
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
@ -124,7 +165,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
MediaManager.scrobble(player.currentMediaItem, false)
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)
@ -133,8 +177,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
@ -145,8 +189,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
@ -154,9 +198,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
@ -175,14 +219,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
librarySessionCallback.buildCustomLayout(player)
)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
librarySessionCallback.buildCustomLayout(player)
)
}
})
@ -190,17 +234,26 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
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()
.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
}
@ -211,19 +264,33 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.release()
mediaLibrarySession.release()
automotiveRepository.deleteMetadata()
clearListener()
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory() =
DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
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()
}
}
}

View file

@ -4,6 +4,8 @@ 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 androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
@ -14,13 +16,13 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
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.google.android.gms.cast.framework.CastContext
@ -34,6 +36,19 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
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"
}
override fun onCreate() {
super.onCreate()
@ -43,10 +58,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
initializeCastPlayer()
initializeMediaLibrarySession()
initializePlayerListener()
initializeEqualizerManager()
setPlayer(
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
)
}
@ -63,18 +79,43 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
}
override fun onDestroy() {
equalizerManager.release()
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 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 initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
@ -87,7 +128,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private fun initializeCastPlayer() {
if (GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) {
castPlayer = CastPlayer(CastContext.getSharedInstance(this))
castPlayer.setSessionAvailabilityListener(this)
@ -96,16 +137,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
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()
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
@ -124,7 +165,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
MediaManager.scrobble(player.currentMediaItem, false)
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)
@ -133,8 +178,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
@ -145,8 +190,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
@ -154,9 +199,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
@ -175,14 +220,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
librarySessionCallback.buildCustomLayout(player)
)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
mediaLibrarySession.setCustomLayout(
librarySessionCallback.buildCustomLayout(player)
librarySessionCallback.buildCustomLayout(player)
)
}
})
@ -190,13 +235,21 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
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()
.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) {
@ -211,19 +264,33 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.release()
mediaLibrarySession.release()
automotiveRepository.deleteMetadata()
clearListener()
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory() =
DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
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()
}
}
}