mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-12 15:37:17 +03:00
Merge remote-tracking branch 'origin/develop' into 480
This commit is contained in:
commit
601d0ccdaa
5
.idea/codeStyles/Project.xml
generated
5
.idea/codeStyles/Project.xml
generated
@ -1,11 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
|
@ -15,6 +15,7 @@ media3 = "1.1.1"
|
||||
androidSupport = "1.7.0"
|
||||
materialDesign = "1.9.0"
|
||||
constraintLayout = "2.1.4"
|
||||
activity = "1.8.0"
|
||||
multidex = "2.0.1"
|
||||
room = "2.5.2"
|
||||
kotlin = "1.9.10"
|
||||
@ -66,6 +67,7 @@ navigationFragmentKtx = { module = "androidx.navigation:navigation-fragment-kt
|
||||
navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
|
||||
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
|
||||
navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"}
|
||||
activity = { module = "androidx.activity:activity-ktx", version.ref = "activity" }
|
||||
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
||||
media3common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
||||
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
|
@ -1,9 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues>
|
||||
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
|
||||
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
|
||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
||||
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||
<CurrentIssues>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
|
||||
@ -11,19 +9,11 @@
|
||||
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
|
||||
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
|
||||
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
|
||||
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
||||
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
||||
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
|
||||
<ID>NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean )</ID>
|
||||
<ID>TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
|
||||
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
||||
</ManuallySuppressedIssues>
|
||||
<CurrentIssues/>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
@ -70,50 +70,6 @@
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Autofill"
|
||||
message="Missing `autofillHints` attribute"
|
||||
errorLine1=" <EditText"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/chat.xml"
|
||||
line="33"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Autofill"
|
||||
message="Missing `autofillHints` attribute"
|
||||
errorLine1=" <EditText"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/save_playlist.xml"
|
||||
line="9"
|
||||
column="6"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Autofill"
|
||||
message="Missing `autofillHints` attribute"
|
||||
errorLine1=" <EditText"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/share_details.xml"
|
||||
line="29"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Autofill"
|
||||
message="Missing `autofillHints` attribute"
|
||||
errorLine1=" <EditText"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/time_span_dialog.xml"
|
||||
line="28"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="LabelFor"
|
||||
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
|
||||
|
@ -22,20 +22,22 @@
|
||||
android:xlargeScreens="true"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:hasFragileUserData="true" tools:targetApi="q"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Material3.DynamicColors.Dark"
|
||||
android:name=".app.UApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/common.appname"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="false"
|
||||
android:preserveLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:ignore="UnusedAttribute">
|
||||
android:preserveLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.Material3.DynamicColors.Dark"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:targetApi="q">
|
||||
<!-- Add for API 34 android:enableOnBackInvokedCallBack="true" -->
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
@ -72,7 +74,7 @@
|
||||
</service>
|
||||
|
||||
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
|
||||
<service android:name=".playback.PlaybackService"
|
||||
<service android:name=".service.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
@ -107,6 +109,12 @@
|
||||
<action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".provider.UltrasonicAppWidgetProvider"
|
||||
android:label="Ultrasonic"
|
||||
@ -119,12 +127,6 @@
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info"/>
|
||||
</receiver>
|
||||
<receiver android:name=".receiver.MediaButtonIntentReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter android:priority="2147483647">
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name=".provider.SearchSuggestionProvider"
|
||||
android:authorities="${applicationId}.provider.SearchSuggestionProvider"
|
||||
@ -137,5 +139,4 @@
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -1,297 +0,0 @@
|
||||
package org.moire.ultrasonic.fragment;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListAdapter;
|
||||
import android.widget.ListView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.moire.ultrasonic.R;
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider;
|
||||
import org.moire.ultrasonic.domain.ChatMessage;
|
||||
import org.moire.ultrasonic.service.MusicService;
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory;
|
||||
import org.moire.ultrasonic.util.BackgroundTask;
|
||||
import org.moire.ultrasonic.util.CancellationToken;
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask;
|
||||
import org.moire.ultrasonic.util.Settings;
|
||||
import org.moire.ultrasonic.util.Util;
|
||||
import org.moire.ultrasonic.view.ChatAdapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import kotlin.Lazy;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
|
||||
/**
|
||||
* Provides online chat functionality
|
||||
*/
|
||||
public class ChatFragment extends Fragment {
|
||||
|
||||
private ListView chatListView;
|
||||
private EditText messageEditText;
|
||||
private MaterialButton sendButton;
|
||||
private Timer timer;
|
||||
private volatile static Long lastChatMessageTime = (long) 0;
|
||||
private static final ArrayList<ChatMessage> messageList = new ArrayList<>();
|
||||
private CancellationToken cancellationToken;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
|
||||
private final Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
Util.applyTheme(this.getContext());
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.chat, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
swipeRefresh = view.findViewById(R.id.chat_refresh);
|
||||
swipeRefresh.setEnabled(false);
|
||||
|
||||
cancellationToken = new CancellationToken();
|
||||
messageEditText = view.findViewById(R.id.chat_edittext);
|
||||
sendButton = view.findViewById(R.id.chat_send);
|
||||
|
||||
sendButton.setOnClickListener(view1 -> sendMessage());
|
||||
|
||||
chatListView = view.findViewById(R.id.chat_entries_list);
|
||||
chatListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL);
|
||||
chatListView.setStackFromBottom(true);
|
||||
|
||||
String serverName = activeServerProvider.getValue().getActiveServer().getName();
|
||||
String userName = activeServerProvider.getValue().getActiveServer().getUserName();
|
||||
String title = String.format("%s [%s@%s]", getResources().getString(R.string.button_bar_chat), userName, serverName);
|
||||
|
||||
FragmentTitle.Companion.setTitle(this, title);
|
||||
setHasOptionsMenu(true);
|
||||
|
||||
messageEditText.setImeActionLabel("Send", KeyEvent.KEYCODE_ENTER);
|
||||
|
||||
messageEditText.addTextChangedListener(new TextWatcher()
|
||||
{
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence charSequence, int i, int i1, int i2)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable)
|
||||
{
|
||||
sendButton.setEnabled(!Util.isNullOrWhiteSpace(editable.toString()));
|
||||
}
|
||||
});
|
||||
|
||||
messageEditText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE || (actionId == EditorInfo.IME_NULL && event.getAction() == KeyEvent.ACTION_DOWN))
|
||||
{
|
||||
sendMessage();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
load();
|
||||
timerMethod();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.chat, menu);
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
}
|
||||
|
||||
/*
|
||||
* Listen for option item selections so that we receive a notification
|
||||
* when the user requests a refresh by selecting the refresh action bar item.
|
||||
*/
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
// Check if user triggered a refresh:
|
||||
if (item.getItemId() == R.id.menu_refresh) {
|
||||
// Start the refresh background task.
|
||||
load();
|
||||
return true;
|
||||
}
|
||||
// User didn't trigger a refresh, let the superclass handle this action
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume()
|
||||
{
|
||||
super.onResume();
|
||||
|
||||
if (!messageList.isEmpty())
|
||||
{
|
||||
ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList);
|
||||
chatListView.setAdapter(chatAdapter);
|
||||
}
|
||||
|
||||
if (timer == null)
|
||||
{
|
||||
timerMethod();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause()
|
||||
{
|
||||
super.onPause();
|
||||
|
||||
if (timer != null)
|
||||
{
|
||||
timer.cancel();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
cancellationToken.cancel();
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
private void timerMethod()
|
||||
{
|
||||
int refreshInterval = Settings.getChatRefreshInterval();
|
||||
|
||||
if (refreshInterval > 0)
|
||||
{
|
||||
timer = new Timer();
|
||||
|
||||
timer.schedule(new TimerTask()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
getActivity().runOnUiThread(() -> load());
|
||||
}
|
||||
}, refreshInterval, refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessage()
|
||||
{
|
||||
if (messageEditText != null)
|
||||
{
|
||||
final String message;
|
||||
Editable text = messageEditText.getText();
|
||||
|
||||
if (text == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message = text.toString();
|
||||
|
||||
if (!Util.isNullOrWhiteSpace(message))
|
||||
{
|
||||
messageEditText.setText("");
|
||||
|
||||
BackgroundTask<Void> task = new FragmentBackgroundTask<Void>(getActivity(), false, swipeRefresh, cancellationToken)
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground() throws Throwable
|
||||
{
|
||||
MusicService musicService = MusicServiceFactory.getMusicService();
|
||||
musicService.addChatMessage(message);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done(Void result)
|
||||
{
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
task.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void load()
|
||||
{
|
||||
BackgroundTask<List<ChatMessage>> task = new FragmentBackgroundTask<List<ChatMessage>>(getActivity(), false, swipeRefresh, cancellationToken)
|
||||
{
|
||||
@Override
|
||||
protected List<ChatMessage> doInBackground() throws Throwable
|
||||
{
|
||||
MusicService musicService = MusicServiceFactory.getMusicService();
|
||||
return musicService.getChatMessages(lastChatMessageTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done(List<ChatMessage> result)
|
||||
{
|
||||
if (result != null && !result.isEmpty())
|
||||
{
|
||||
// Reset lastChatMessageTime if we have a newer message
|
||||
for (ChatMessage message : result)
|
||||
{
|
||||
if (message.getTime() > lastChatMessageTime)
|
||||
{
|
||||
lastChatMessageTime = message.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse results to show them on the bottom
|
||||
Collections.reverse(result);
|
||||
messageList.addAll(result);
|
||||
|
||||
ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList);
|
||||
chatListView.setAdapter(chatAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void error(Throwable error) {
|
||||
// Stop the timer in case of an error, otherwise it may repeat the error message forever
|
||||
if (timer != null)
|
||||
{
|
||||
timer.cancel();
|
||||
timer = null;
|
||||
}
|
||||
super.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
task.execute();
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
/*
|
||||
This file is part of Subsonic.
|
||||
|
||||
Subsonic is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Subsonic is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright 2009 (C) Sindre Mehus
|
||||
*/
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Handler;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
public abstract class BackgroundTask<T> implements ProgressListener
|
||||
{
|
||||
private final Activity activity;
|
||||
private final Handler handler;
|
||||
|
||||
public BackgroundTask(Activity activity)
|
||||
{
|
||||
this.activity = activity;
|
||||
handler = new Handler();
|
||||
}
|
||||
|
||||
protected Activity getActivity()
|
||||
{
|
||||
return activity;
|
||||
}
|
||||
|
||||
protected Handler getHandler()
|
||||
{
|
||||
return handler;
|
||||
}
|
||||
|
||||
public abstract void execute();
|
||||
|
||||
protected abstract T doInBackground() throws Throwable;
|
||||
|
||||
protected abstract void done(T result);
|
||||
|
||||
protected void error(Throwable error)
|
||||
{
|
||||
CommunicationError.handleError(error, activity);
|
||||
}
|
||||
|
||||
protected String getErrorMessage(Throwable error)
|
||||
{
|
||||
return CommunicationError.getErrorMessage(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract void updateProgress(final String message);
|
||||
|
||||
@Override
|
||||
public void updateProgress(int messageId)
|
||||
{
|
||||
updateProgress(activity.getResources().getString(messageId));
|
||||
}
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
* @version $Id$
|
||||
*/
|
||||
public abstract class FragmentBackgroundTask<T> extends BackgroundTask<T>
|
||||
{
|
||||
private final boolean changeProgress;
|
||||
private final SwipeRefreshLayout swipe;
|
||||
private final CancellationToken cancel;
|
||||
|
||||
public FragmentBackgroundTask(Activity activity, boolean changeProgress,
|
||||
SwipeRefreshLayout swipe, CancellationToken cancel)
|
||||
{
|
||||
super(activity);
|
||||
this.changeProgress = changeProgress;
|
||||
this.swipe = swipe;
|
||||
this.cancel = cancel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute()
|
||||
{
|
||||
if (changeProgress)
|
||||
{
|
||||
if (swipe != null) swipe.setRefreshing(true);
|
||||
}
|
||||
|
||||
new Thread()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
final T result = doInBackground();
|
||||
if (cancel.isCancellationRequested())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
getHandler().post(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
if (changeProgress)
|
||||
{
|
||||
if (swipe != null) swipe.setRefreshing(false);
|
||||
}
|
||||
|
||||
done(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (final Throwable t)
|
||||
{
|
||||
if (cancel.isCancellationRequested())
|
||||
{
|
||||
return;
|
||||
}
|
||||
getHandler().post(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
if (changeProgress)
|
||||
{
|
||||
if (swipe != null) swipe.setRefreshing(false);
|
||||
}
|
||||
|
||||
error(t);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateProgress(final String message)
|
||||
{
|
||||
}
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
* @version $Id$
|
||||
*/
|
||||
public abstract class LoadingTask<T> extends BackgroundTask<T>
|
||||
{
|
||||
private final SwipeRefreshLayout swipe;
|
||||
private final CancellationToken cancel;
|
||||
|
||||
public LoadingTask(Activity activity, SwipeRefreshLayout swipe, CancellationToken cancel)
|
||||
{
|
||||
super(activity);
|
||||
this.swipe = swipe;
|
||||
this.cancel = cancel;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void execute()
|
||||
{
|
||||
swipe.setRefreshing(true);
|
||||
|
||||
new Thread()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
final T result = doInBackground();
|
||||
if (cancel.isCancellationRequested())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
getHandler().post(() -> {
|
||||
swipe.setRefreshing(false);
|
||||
done(result);
|
||||
});
|
||||
}
|
||||
catch (final Throwable t)
|
||||
{
|
||||
if (cancel.isCancellationRequested())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
getHandler().post(() -> {
|
||||
swipe.setRefreshing(false);
|
||||
error(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateProgress(final String message)
|
||||
{
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
/*
|
||||
This file is part of Subsonic.
|
||||
|
||||
Subsonic is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Subsonic is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright 2009 (C) Sindre Mehus
|
||||
*/
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
public interface ProgressListener
|
||||
{
|
||||
void updateProgress(String message);
|
||||
|
||||
void updateProgress(int messageId);
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import org.moire.ultrasonic.domain.Track;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Created by Josh on 12/17/13.
|
||||
*/
|
||||
public class ShareDetails
|
||||
{
|
||||
public String Description;
|
||||
public boolean ShareOnServer;
|
||||
public long Expiration;
|
||||
public List<Track> Entries;
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import androidx.preference.DialogPreference;
|
||||
import org.moire.ultrasonic.R;
|
||||
|
||||
/**
|
||||
* Created by Joshua Bahnsen on 12/22/13.
|
||||
*/
|
||||
public class TimeSpanPreference extends DialogPreference
|
||||
{
|
||||
Context context;
|
||||
|
||||
public TimeSpanPreference(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
this.context = context;
|
||||
|
||||
setPositiveButtonText(android.R.string.ok);
|
||||
setNegativeButtonText(android.R.string.cancel);
|
||||
|
||||
setDialogIcon(null);
|
||||
|
||||
}
|
||||
|
||||
public String getText()
|
||||
{
|
||||
String persisted = getPersistedString("");
|
||||
|
||||
if (!"".equals(persisted))
|
||||
{
|
||||
return persisted.replace(':', ' ');
|
||||
}
|
||||
|
||||
return this.context.getResources().getString(R.string.time_span_disabled);
|
||||
}
|
||||
}
|
@ -23,7 +23,6 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
@ -39,6 +38,7 @@ import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.navigateUp
|
||||
import androidx.navigation.ui.onNavDestinationSelected
|
||||
@ -50,6 +50,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.scope.ScopeActivity
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
@ -63,7 +64,6 @@ import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.LocaleHelper
|
||||
@ -81,7 +81,7 @@ import timber.log.Timber
|
||||
* onCreate/onResume/onDestroy methods...
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class NavigationActivity : AppCompatActivity() {
|
||||
class NavigationActivity : ScopeActivity() {
|
||||
private var videoMenuItem: MenuItem? = null
|
||||
private var chatMenuItem: MenuItem? = null
|
||||
private var bookmarksMenuItem: MenuItem? = null
|
||||
@ -485,15 +485,19 @@ class NavigationActivity : AppCompatActivity() {
|
||||
val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getRandomSongs(Settings.maxSongs)
|
||||
val downloadHandler: DownloadHandler by inject()
|
||||
downloadHandler.addTracksToMediaController(
|
||||
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs = musicDirectory.getTracks(),
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
fragment = currentFragment,
|
||||
playlistName = null
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR
|
||||
)
|
||||
|
||||
if (Settings.shouldTransitionOnPlayback) {
|
||||
currentFragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
currentFragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,10 @@ import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.drakeet.multitype.ItemViewBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
@ -63,16 +67,21 @@ class ArtistRowBinder(
|
||||
|
||||
if (showArtistPicture()) {
|
||||
holder.coverArt.visibility = View.VISIBLE
|
||||
val key = FileUtil.getArtistArtKey(item.name, false)
|
||||
imageLoaderProvider.executeOn {
|
||||
it.loadImage(
|
||||
view = holder.coverArt,
|
||||
id = holder.coverArtId,
|
||||
key = key,
|
||||
large = false,
|
||||
size = 0,
|
||||
defaultResourceId = R.drawable.ic_contact_picture
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val key = FileUtil.getArtistArtKey(item.name, false)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
imageLoaderProvider.executeOn {
|
||||
it.loadImage(
|
||||
view = holder.coverArt,
|
||||
id = holder.coverArtId,
|
||||
key = key,
|
||||
large = false,
|
||||
size = 0,
|
||||
defaultResourceId = R.drawable.ic_contact_picture
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
holder.coverArt.visibility = View.GONE
|
||||
|
@ -31,7 +31,6 @@ import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
const val INDICATOR_THICKNESS_INDEFINITE = 5
|
||||
const val INDICATOR_THICKNESS_DEFINITE = 10
|
||||
@ -79,10 +78,6 @@ class TrackViewHolder(val view: View) :
|
||||
|
||||
private var rxBusSubscription: CompositeDisposable? = null
|
||||
|
||||
init {
|
||||
Timber.v("New ViewHolder created")
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod")
|
||||
fun setSong(
|
||||
song: Track,
|
||||
|
@ -7,8 +7,6 @@
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.room.Room
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -106,6 +104,30 @@ class ActiveServerProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Active Server by its unique id
|
||||
* @param serverId: The id of the desired server
|
||||
*/
|
||||
fun setActiveServerById(serverId: Int) {
|
||||
val oldServerId = Settings.activeServer
|
||||
if (oldServerId == serverId) return
|
||||
|
||||
// Notify components about the change before actually resetting the MusicService
|
||||
// so they can react by e.g. stopping playback on the old server
|
||||
RxBus.activeServerChangingPublisher.onNext(oldServerId)
|
||||
|
||||
// Use a coroutine to post the server change to the end of the message queue
|
||||
launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
resetMusicService()
|
||||
Settings.activeServer = serverId
|
||||
|
||||
RxBus.activeServerChangedPublisher.onNext(getActiveServer(serverId))
|
||||
Timber.i("setActiveServerById done, new id: %s", serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getActiveMetaDatabase(): MetaDatabase {
|
||||
val activeServer = getActiveServerId()
|
||||
@ -234,29 +256,6 @@ class ActiveServerProvider(
|
||||
return Settings.activeServer
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Active Server by its unique id
|
||||
* @param serverId: The id of the desired server
|
||||
*/
|
||||
fun setActiveServerById(serverId: Int) {
|
||||
val oldServerId = Settings.activeServer
|
||||
if (oldServerId == serverId) return
|
||||
|
||||
// Notify components about the change before actually resetting the MusicService
|
||||
// so they can react by e.g. stopping playback on the old server
|
||||
RxBus.activeServerChangingPublisher.onNext(oldServerId)
|
||||
|
||||
// Post the server change to the end of the message queue,
|
||||
// so the cleanup have time to finish
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
resetMusicService()
|
||||
Settings.activeServer = serverId
|
||||
|
||||
RxBus.activeServerChangedPublisher.onNext(serverId)
|
||||
Timber.i("setActiveServerById done, new id: %s", serverId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries if Scrobbling is enabled
|
||||
*/
|
||||
|
@ -1,11 +1,11 @@
|
||||
/*
|
||||
* CachedDataSource.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
@ -1,14 +1,15 @@
|
||||
package org.moire.ultrasonic.di
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
|
||||
/**
|
||||
* This Koin module contains the registration of general classes needed for Ultrasonic
|
||||
*/
|
||||
val applicationModule = module {
|
||||
single { ActiveServerProvider(get()) }
|
||||
single { ImageLoaderProvider(androidContext()) }
|
||||
single { ImageLoaderProvider() }
|
||||
single { CacheCleaner() }
|
||||
}
|
||||
|
@ -1,19 +1,27 @@
|
||||
package org.moire.ultrasonic.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
|
||||
/**
|
||||
* This Koin module contains the registration of classes related to the media player
|
||||
*/
|
||||
val mediaPlayerModule = module {
|
||||
single { MediaPlayerLifecycleSupport() }
|
||||
|
||||
// These are dependency-free
|
||||
single { PlaybackStateSerializer() }
|
||||
single { ExternalStorageMonitor() }
|
||||
single { NetworkAndStorageChecker() }
|
||||
single { ShareHandler() }
|
||||
|
||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||
single { MediaPlayerManager(get(), get(), get()) }
|
||||
scope<NavigationActivity> {
|
||||
scoped { MediaPlayerManager(get(), get()) }
|
||||
scoped { MediaPlayerLifecycleSupport(get(), get(), get(), get()) }
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package org.moire.ultrasonic.di
|
||||
|
||||
import kotlin.math.abs
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.BuildConfig
|
||||
@ -16,9 +15,6 @@ import org.moire.ultrasonic.service.CachedMusicService
|
||||
import org.moire.ultrasonic.service.MusicService
|
||||
import org.moire.ultrasonic.service.OfflineMusicService
|
||||
import org.moire.ultrasonic.service.RESTMusicService
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
|
||||
/**
|
||||
@ -68,8 +64,4 @@ val musicServiceModule = module {
|
||||
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {
|
||||
OfflineMusicService()
|
||||
}
|
||||
|
||||
single { DownloadHandler(get(), get()) }
|
||||
single { NetworkAndStorageChecker(androidContext()) }
|
||||
single { ShareHandler(androidContext()) }
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import java.util.Locale
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.Util.getVersionName
|
||||
|
||||
@ -56,7 +55,7 @@ class AboutFragment : Fragment() {
|
||||
versionName
|
||||
)
|
||||
|
||||
setTitle(this@AboutFragment, getString(R.string.menu_about))
|
||||
FragmentTitle.setTitle(this@AboutFragment, getString(R.string.menu_about))
|
||||
titleText?.text = title
|
||||
|
||||
webPageButton?.setOnClickListener {
|
||||
|
@ -5,8 +5,6 @@
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
@file:Suppress("NAME_SHADOWING")
|
||||
|
||||
package org.moire.ultrasonic.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
@ -32,6 +30,7 @@ import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.model.AlbumListModel
|
||||
import org.moire.ultrasonic.util.LayoutType
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.FilterButtonBar
|
||||
import org.moire.ultrasonic.view.SortOrder
|
||||
import org.moire.ultrasonic.view.ViewCapabilities
|
||||
@ -76,9 +75,10 @@ class AlbumListFragment(
|
||||
}
|
||||
|
||||
private fun fetchAlbums(refresh: Boolean = navArgs.refresh, append: Boolean = navArgs.append) {
|
||||
|
||||
listModel.viewModelScope.launch(handler) {
|
||||
refreshListView?.isRefreshing = true
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
|
||||
if (navArgs.byArtist) {
|
||||
listModel.getAlbumsOfArtist(
|
||||
@ -95,7 +95,7 @@ class AlbumListFragment(
|
||||
refresh = refresh or append
|
||||
)
|
||||
}
|
||||
refreshListView?.isRefreshing = false
|
||||
swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,8 +185,8 @@ class AlbumListFragment(
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Setup refresh handler
|
||||
refreshListView = view.findViewById(refreshListId)
|
||||
refreshListView?.setOnRefreshListener {
|
||||
swipeRefresh = view.findViewById(refreshListId)
|
||||
swipeRefresh?.setOnRefreshListener {
|
||||
fetchAlbums(refresh = true)
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
|
||||
* The central function to pass a query to the model and return a LiveData object
|
||||
*/
|
||||
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<ArtistOrIndex>> {
|
||||
return listModel.getItems(navArgs.refresh || refresh, refreshListView!!)
|
||||
return listModel.getItems(navArgs.refresh || refresh, swipeRefresh!!)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -16,8 +16,9 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.service.PlaybackState
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Lists the Bookmarks available on the server
|
||||
@ -40,10 +41,12 @@ class BookmarksFragment : TrackCollectionFragment() {
|
||||
refresh: Boolean,
|
||||
append: Boolean
|
||||
): LiveData<List<MusicDirectory.Child>> {
|
||||
listModel.viewModelScope.launch(handler) {
|
||||
refreshListView?.isRefreshing = true
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
listModel.getBookmarks()
|
||||
refreshListView?.isRefreshing = false
|
||||
swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
return listModel.currentList
|
||||
}
|
||||
@ -61,22 +64,21 @@ class BookmarksFragment : TrackCollectionFragment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom playback function which uses the restore functionality. A bit of a hack..
|
||||
* Play the selected tracks at the bookmarked position
|
||||
*/
|
||||
private fun playNow(songs: List<Track>) {
|
||||
if (songs.isNotEmpty()) {
|
||||
|
||||
val state = PlaybackState(
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs = songs,
|
||||
currentPlayingIndex = 0,
|
||||
currentPlayingPosition = songs[0].bookmarkPosition
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR
|
||||
)
|
||||
|
||||
mediaPlayerManager.restore(
|
||||
state = state,
|
||||
autoPlay = true,
|
||||
newPlaylist = true
|
||||
)
|
||||
mediaPlayerManager.seekTo(0, songs[0].bookmarkPosition)
|
||||
mediaPlayerManager.prepare()
|
||||
mediaPlayerManager.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,10 +11,10 @@ import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.moire.ultrasonic.R
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.adapters.FolderSelectorBinder
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
@ -23,17 +23,17 @@ import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
|
||||
|
||||
/**
|
||||
* An extension of the MultiListFragment, with a few helper functions geared
|
||||
* towards the display of MusicDirectory.Entries.
|
||||
* @param T: The type of data which will be used (must extend GenericEntry)
|
||||
*/
|
||||
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>(), KoinScopeComponent {
|
||||
|
||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
/**
|
||||
* Whether to show the folder selector
|
||||
@ -46,7 +46,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
|
||||
val isArtist = (item is Artist)
|
||||
|
||||
return handleContextMenu(menuItem, item, isArtist, downloadHandler, this)
|
||||
return handleContextMenu(menuItem, item, isArtist, mediaPlayerManager, this)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: T) {
|
||||
@ -73,7 +73,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
currentSetting.musicFolderId = it.id
|
||||
serverSettingsModel.updateItem(currentSetting)
|
||||
}
|
||||
listModel.refresh(refreshListView!!)
|
||||
listModel.refresh(swipeRefresh!!)
|
||||
}
|
||||
|
||||
viewAdapter.register(
|
||||
@ -90,7 +90,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
* What to do when the list has changed
|
||||
*/
|
||||
override val defaultObserver: (List<T>) -> Unit = {
|
||||
emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing ?: false)
|
||||
emptyView.isVisible = it.isEmpty() && !(swipeRefresh?.isRefreshing ?: false)
|
||||
|
||||
if (showFolderHeader()) {
|
||||
val list = mutableListOf<Identifiable>(folderHeader)
|
||||
@ -119,65 +119,4 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("LongMethod")
|
||||
internal fun handleContextMenu(
|
||||
menuItem: MenuItem,
|
||||
item: Identifiable,
|
||||
isArtist: Boolean,
|
||||
downloadHandler: DownloadHandler,
|
||||
fragment: Fragment
|
||||
): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_play_now ->
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
autoPlay = true,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_next ->
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
|
||||
autoPlay = true,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_last ->
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
|
||||
autoPlay = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_pin ->
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment,
|
||||
item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_unpin ->
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment,
|
||||
item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_download ->
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment,
|
||||
item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ import java.util.HashMap
|
||||
import java.util.Locale
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -7,24 +7,22 @@ import androidx.navigation.fragment.NavHostFragment
|
||||
/**
|
||||
* Contains utility functions related to Fragment title handling
|
||||
*/
|
||||
class FragmentTitle {
|
||||
companion object {
|
||||
fun setTitle(fragment: Fragment, title: CharSequence?) {
|
||||
// Only set the title if our fragment is a direct child of the NavHostFragment...
|
||||
if (fragment.parentFragment is NavHostFragment) {
|
||||
(fragment.activity as AppCompatActivity).supportActionBar?.title = title
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(fragment: Fragment, id: Int) {
|
||||
// Only set the title if our fragment is a direct child of the NavHostFragment...
|
||||
if (fragment.parentFragment is NavHostFragment) {
|
||||
(fragment.activity as AppCompatActivity).supportActionBar?.setTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTitle(fragment: Fragment): CharSequence? {
|
||||
return (fragment.activity as AppCompatActivity).supportActionBar?.title
|
||||
object FragmentTitle {
|
||||
fun setTitle(fragment: Fragment, title: CharSequence?) {
|
||||
// Only set the title if our fragment is a direct child of the NavHostFragment...
|
||||
if (fragment.parentFragment is NavHostFragment) {
|
||||
(fragment.activity as AppCompatActivity).supportActionBar?.title = title
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(fragment: Fragment, id: Int) {
|
||||
// Only set the title if our fragment is a direct child of the NavHostFragment...
|
||||
if (fragment.parentFragment is NavHostFragment) {
|
||||
(fragment.activity as AppCompatActivity).supportActionBar?.setTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTitle(fragment: Fragment): CharSequence? {
|
||||
return (fragment.activity as AppCompatActivity).supportActionBar?.title
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,11 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import java.lang.ref.SoftReference
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.collections.hashMapOf
|
||||
import kotlin.collections.set
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
@ -34,7 +38,7 @@ import org.moire.ultrasonic.view.SortOrder
|
||||
import org.moire.ultrasonic.view.ViewCapabilities
|
||||
import timber.log.Timber
|
||||
|
||||
class MainFragment : Fragment(), KoinComponent {
|
||||
class MainFragment : ScopeFragment(), KoinScopeComponent {
|
||||
|
||||
private var filterButtonBar: FilterButtonBar? = null
|
||||
private var layoutType: LayoutType = LayoutType.COVER
|
||||
|
@ -8,8 +8,6 @@
|
||||
package org.moire.ultrasonic.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
@ -17,15 +15,14 @@ import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||
@ -33,21 +30,19 @@ import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.model.GenericListModel
|
||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Util
|
||||
|
||||
/**
|
||||
* An abstract Model, which can be extended to display a list of items of type T from the API
|
||||
* @param T: The type of data which will be used (must extend GenericEntry)
|
||||
*/
|
||||
abstract class MultiListFragment<T : Identifiable> : Fragment() {
|
||||
abstract class MultiListFragment<T : Identifiable> : ScopeFragment(), RefreshableFragment {
|
||||
internal val activeServerProvider: ActiveServerProvider by inject()
|
||||
internal val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
internal val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
protected val downloadHandler: DownloadHandler by inject()
|
||||
protected var refreshListView: SwipeRefreshLayout? = null
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
internal var listView: RecyclerView? = null
|
||||
internal lateinit var viewManager: LinearLayoutManager
|
||||
internal lateinit var emptyView: ConstraintLayout
|
||||
@ -97,16 +92,6 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
|
||||
*/
|
||||
open val refreshOnCreation: Boolean = true
|
||||
|
||||
/**
|
||||
* The default Exception Handler for Coroutines
|
||||
*/
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
CommunicationError.handleError(exception, context)
|
||||
}
|
||||
refreshListView?.isRefreshing = false
|
||||
}
|
||||
|
||||
open fun setTitle(title: String?) {
|
||||
if (title == null) {
|
||||
FragmentTitle.setTitle(
|
||||
@ -124,7 +109,7 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
|
||||
* What to do when the list has changed
|
||||
*/
|
||||
internal open val defaultObserver: ((List<T>) -> Unit) = {
|
||||
emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing?:false)
|
||||
emptyView.isVisible = it.isEmpty() && !(swipeRefresh?.isRefreshing?:false)
|
||||
viewAdapter.submitList(it)
|
||||
}
|
||||
|
||||
@ -132,9 +117,9 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Setup refresh handler
|
||||
refreshListView = view.findViewById(refreshListId)
|
||||
refreshListView?.setOnRefreshListener {
|
||||
listModel.refresh(refreshListView!!)
|
||||
swipeRefresh = view.findViewById(refreshListId)
|
||||
swipeRefresh?.setOnRefreshListener {
|
||||
listModel.refresh(swipeRefresh!!)
|
||||
}
|
||||
|
||||
// Populate the LiveData. This starts an API request in most cases
|
||||
|
@ -15,7 +15,6 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@ -23,6 +22,7 @@ import io.reactivex.rxjava3.disposables.Disposable
|
||||
import java.lang.Exception
|
||||
import kotlin.math.abs
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
@ -37,7 +37,7 @@ import timber.log.Timber
|
||||
/**
|
||||
* Contains the mini-now playing information box displayed at the bottom of the screen
|
||||
*/
|
||||
class NowPlayingFragment : Fragment() {
|
||||
class NowPlayingFragment : ScopeFragment() {
|
||||
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
|
@ -45,7 +45,6 @@ import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
@ -79,12 +78,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||
import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
|
||||
@ -93,7 +92,7 @@ import org.moire.ultrasonic.databinding.CurrentPlayingBinding
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
@ -106,6 +105,7 @@ import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.toTrack
|
||||
import org.moire.ultrasonic.view.AutoRepeatButton
|
||||
import timber.log.Timber
|
||||
@ -116,9 +116,9 @@ import timber.log.Timber
|
||||
*/
|
||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||
class PlayerFragment :
|
||||
Fragment(),
|
||||
ScopeFragment(),
|
||||
GestureDetector.OnGestureListener,
|
||||
KoinComponent,
|
||||
KoinScopeComponent,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||
|
||||
// Settings
|
||||
@ -356,14 +356,14 @@ class PlayerFragment :
|
||||
onPlaylistChanged()
|
||||
|
||||
when (newRepeat) {
|
||||
0 -> Util.toast(
|
||||
context, R.string.download_repeat_off
|
||||
0 -> toast(
|
||||
R.string.download_repeat_off
|
||||
)
|
||||
1 -> Util.toast(
|
||||
context, R.string.download_repeat_single
|
||||
1 -> toast(
|
||||
R.string.download_repeat_single
|
||||
)
|
||||
2 -> Util.toast(
|
||||
context, R.string.download_repeat_all
|
||||
2 -> toast(
|
||||
R.string.download_repeat_all
|
||||
)
|
||||
else -> {
|
||||
}
|
||||
@ -410,7 +410,7 @@ class PlayerFragment :
|
||||
// Query the Jukebox state in an IO Context
|
||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||
try {
|
||||
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
|
||||
jukeboxAvailable = getMusicService().isJukeboxAvailable()
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
@ -457,9 +457,9 @@ class PlayerFragment :
|
||||
val isEnabled = mediaPlayerManager.toggleShuffle()
|
||||
|
||||
if (isEnabled) {
|
||||
Util.toast(activity, R.string.download_menu_shuffle_on)
|
||||
toast(R.string.download_menu_shuffle_on)
|
||||
} else {
|
||||
Util.toast(activity, R.string.download_menu_shuffle_off)
|
||||
toast(R.string.download_menu_shuffle_off)
|
||||
}
|
||||
|
||||
updateShuffleButtonState(isEnabled)
|
||||
@ -579,8 +579,7 @@ class PlayerFragment :
|
||||
equalizerMenuItem.isVisible = isEqualizerAvailable
|
||||
}
|
||||
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
val track = mediaPlayerManager.currentMediaItem?.toTrack()
|
||||
|
||||
if (track != null) {
|
||||
currentSong = track
|
||||
@ -600,7 +599,7 @@ class PlayerFragment :
|
||||
goToArtist.isVisible = false
|
||||
}
|
||||
|
||||
if (mediaPlayerController.keepScreenOn) {
|
||||
if (mediaPlayerManager.keepScreenOn) {
|
||||
screenOption?.setTitle(R.string.download_menu_screen_off)
|
||||
} else {
|
||||
screenOption?.setTitle(R.string.download_menu_screen_on)
|
||||
@ -609,7 +608,7 @@ class PlayerFragment :
|
||||
if (jukeboxOption != null) {
|
||||
jukeboxOption.isEnabled = jukeboxAvailable
|
||||
jukeboxOption.isVisible = jukeboxAvailable
|
||||
if (mediaPlayerController.isJukeboxEnabled) {
|
||||
if (mediaPlayerManager.isJukeboxEnabled) {
|
||||
jukeboxOption.setTitle(R.string.download_menu_jukebox_off)
|
||||
} else {
|
||||
jukeboxOption.setTitle(R.string.download_menu_jukebox_on)
|
||||
@ -707,8 +706,7 @@ class PlayerFragment :
|
||||
R.id.menu_item_jukebox -> {
|
||||
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
|
||||
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
|
||||
Util.toast(
|
||||
context,
|
||||
toast(
|
||||
if (jukeboxEnabled) R.string.download_jukebox_on
|
||||
else R.string.download_jukebox_off,
|
||||
false
|
||||
@ -760,7 +758,7 @@ class PlayerFragment :
|
||||
R.string.download_bookmark_set_at_position,
|
||||
bookmarkTime
|
||||
)
|
||||
Util.toast(context, msg)
|
||||
toast(msg)
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_bookmark_delete -> {
|
||||
@ -776,36 +774,25 @@ class PlayerFragment :
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
Util.toast(context, R.string.download_bookmark_removed)
|
||||
toast(R.string.download_bookmark_removed)
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_share -> {
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
val tracks: MutableList<Track?> = ArrayList()
|
||||
val playlist = mediaPlayerController.playlist
|
||||
for (item in playlist) {
|
||||
val playlistEntry = item.toTrack()
|
||||
tracks.add(playlistEntry)
|
||||
val tracks = mediaPlayerManager.playlist.map {
|
||||
it.toTrack()
|
||||
}
|
||||
shareHandler.createShare(
|
||||
this,
|
||||
tracks = tracks,
|
||||
swipe = null,
|
||||
cancellationToken = cancellationToken,
|
||||
)
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_share_song -> {
|
||||
if (track == null) return true
|
||||
|
||||
val tracks: MutableList<Track?> = ArrayList()
|
||||
tracks.add(track)
|
||||
|
||||
shareHandler.createShare(
|
||||
this,
|
||||
tracks,
|
||||
swipe = null,
|
||||
cancellationToken = cancellationToken
|
||||
listOf(track),
|
||||
)
|
||||
return true
|
||||
}
|
||||
@ -822,7 +809,7 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun savePlaylistInBackground(playlistName: String) {
|
||||
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
|
||||
toast(resources.getString(R.string.download_playlist_saving, playlistName))
|
||||
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||
|
||||
// The playlist can be acquired only from the main thread
|
||||
@ -835,7 +822,7 @@ class PlayerFragment :
|
||||
musicService.createPlaylist(null, playlistName, entries)
|
||||
}.invokeOnCompletion {
|
||||
if (it == null || it is CancellationException) {
|
||||
Util.toast(UApp.applicationContext(), R.string.download_playlist_done)
|
||||
toast(R.string.download_playlist_done)
|
||||
} else {
|
||||
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
|
||||
val msg = String.format(
|
||||
@ -844,7 +831,7 @@ class PlayerFragment :
|
||||
resources.getString(R.string.download_playlist_error),
|
||||
CommunicationError.getErrorMessage(it)
|
||||
)
|
||||
Util.toast(UApp.applicationContext(), msg)
|
||||
toast(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -958,7 +945,7 @@ class PlayerFragment :
|
||||
item?.mediaMetadata?.title
|
||||
)
|
||||
|
||||
Util.toast(context, songRemoved)
|
||||
toast(songRemoved)
|
||||
|
||||
// Remove the item from the playlist
|
||||
mediaPlayerManager.removeFromPlaylist(pos)
|
||||
@ -1059,15 +1046,14 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun onPlaylistChanged() {
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
// Try to display playlist in play order
|
||||
val list = mediaPlayerController.playlistInPlayOrder
|
||||
val list = mediaPlayerManager.playlistInPlayOrder
|
||||
emptyTextView.setText(R.string.playlist_empty)
|
||||
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
||||
progressIndicator.isVisible = false
|
||||
emptyView.isVisible = list.isEmpty()
|
||||
|
||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||
updateRepeatButtonState(mediaPlayerManager.repeatMode)
|
||||
}
|
||||
|
||||
private fun onTrackChanged() {
|
||||
|
@ -17,7 +17,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.AlbumRowDelegate
|
||||
@ -34,43 +34,32 @@ import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.Index
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.model.SearchListModel
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
|
||||
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenuTracks
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Initiates a search on the media library and displays the results
|
||||
|
||||
*/
|
||||
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
class SearchFragment : MultiListFragment<Identifiable>(), KoinScopeComponent, RefreshableFragment {
|
||||
private var searchResult: SearchResult? = null
|
||||
private var searchRefresh: SwipeRefreshLayout? = null
|
||||
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
|
||||
private val navArgs by navArgs<SearchFragmentArgs>()
|
||||
|
||||
override val listModel: SearchListModel by viewModels()
|
||||
|
||||
override val mainLayout: Int = R.layout.search
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
cancellationToken = CancellationToken()
|
||||
setTitle(this, R.string.search_title)
|
||||
|
||||
listModel.searchResult.observe(
|
||||
@ -83,8 +72,8 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
searchRefresh = view.findViewById(R.id.swipe_refresh_view)
|
||||
searchRefresh!!.isEnabled = false
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh_view)
|
||||
swipeRefresh!!.isEnabled = false
|
||||
|
||||
registerForContextMenu(listView!!)
|
||||
|
||||
@ -133,29 +122,17 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
|
||||
override fun onDestroyView() {
|
||||
Util.hideKeyboard(activity)
|
||||
cancellationToken?.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun downloadBackground(save: Boolean, songs: List<Track?>) {
|
||||
val onValid = Runnable {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
DownloadService.download(
|
||||
songs.filterNotNull(),
|
||||
save = save,
|
||||
updateSaveFlag = true
|
||||
)
|
||||
}
|
||||
onValid.run()
|
||||
}
|
||||
|
||||
private fun search(query: String, autoplay: Boolean) {
|
||||
listModel.viewModelScope.launch(CommunicationError.getHandler(context)) {
|
||||
refreshListView?.isRefreshing = true
|
||||
listModel.search(query)
|
||||
refreshListView?.isRefreshing = false
|
||||
}.invokeOnCompletion {
|
||||
if (it == null && autoplay) {
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
val result = listModel.search(query)
|
||||
swipeRefresh?.isRefreshing = false
|
||||
if (result != null && autoplay) {
|
||||
autoplay()
|
||||
}
|
||||
}
|
||||
@ -253,7 +230,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND
|
||||
)
|
||||
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
|
||||
toast(context, resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
|
||||
toast(resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
|
||||
}
|
||||
|
||||
private fun onVideoSelected(track: Track) {
|
||||
@ -288,103 +265,23 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
|
||||
val isArtist = (item is Artist)
|
||||
|
||||
val found = EntryListFragment.handleContextMenu(
|
||||
menuItem,
|
||||
item,
|
||||
isArtist,
|
||||
downloadHandler,
|
||||
this
|
||||
)
|
||||
|
||||
if (found || item !is Track) return true
|
||||
|
||||
val songs = mutableListOf<Track>()
|
||||
|
||||
when (menuItem.itemId) {
|
||||
R.id.song_menu_play_now -> {
|
||||
songs.add(item)
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
songs.add(item)
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
songs.add(item)
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_pin -> {
|
||||
songs.add(item)
|
||||
toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.n_songs_pinned,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
downloadBackground(true, songs)
|
||||
}
|
||||
R.id.song_menu_download -> {
|
||||
songs.add(item)
|
||||
toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.n_songs_to_be_downloaded,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
downloadBackground(false, songs)
|
||||
}
|
||||
R.id.song_menu_unpin -> {
|
||||
songs.add(item)
|
||||
toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.n_songs_unpinned,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
DownloadService.unpin(songs)
|
||||
}
|
||||
R.id.song_menu_share -> {
|
||||
songs.add(item)
|
||||
shareHandler.createShare(
|
||||
fragment = this,
|
||||
tracks = songs,
|
||||
swipe = searchRefresh,
|
||||
cancellationToken = cancellationToken!!,
|
||||
additionalId = null
|
||||
)
|
||||
}
|
||||
// Here the Item could be a track or an album or an artist
|
||||
if (item is Track) {
|
||||
return handleContextMenuTracks(
|
||||
menuItem = menuItem,
|
||||
tracks = listOf(item),
|
||||
mediaPlayerManager = mediaPlayerManager,
|
||||
fragment = this
|
||||
)
|
||||
} else {
|
||||
return handleContextMenu(
|
||||
menuItem = menuItem,
|
||||
item = item,
|
||||
isArtist = item is Artist,
|
||||
mediaPlayerManager = mediaPlayerManager,
|
||||
fragment = this
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -64,7 +64,7 @@ class ServerSelectorFragment : Fragment() {
|
||||
|
||||
listView?.onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||
val server = parent.getItemAtPosition(position) as ServerSetting
|
||||
ActiveServerProvider.setActiveServerById(server.id)
|
||||
activeServerProvider.setActiveServerById(server.id)
|
||||
findNavController().popBackStack(R.id.mainFragment, false)
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ class ServerSelectorFragment : Fragment() {
|
||||
val activeServerId = ActiveServerProvider.getActiveServerId()
|
||||
|
||||
// If the currently active server is deleted, go offline
|
||||
if (id == activeServerId) ActiveServerProvider.setActiveServerById(OFFLINE_DB_ID)
|
||||
if (id == activeServerId) activeServerProvider.setActiveServerById(OFFLINE_DB_ID)
|
||||
|
||||
serverSettingsModel.deleteItemById(id)
|
||||
|
||||
|
@ -20,7 +20,7 @@ import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.log.FileLoggerTree
|
||||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.deleteLogFiles
|
||||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileNumber
|
||||
@ -300,7 +300,7 @@ class SettingsFragment :
|
||||
SearchSuggestionProvider.MODE
|
||||
)
|
||||
suggestions.clearHistory()
|
||||
toast(activity, R.string.settings_search_history_cleared)
|
||||
toast(R.string.settings_search_history_cleared)
|
||||
false
|
||||
}
|
||||
}
|
||||
@ -332,7 +332,7 @@ class SettingsFragment :
|
||||
Timber.w("Failed to delete %s", nomediaDir)
|
||||
}
|
||||
}
|
||||
toast(activity, R.string.settings_hide_media_toast, false)
|
||||
toast(R.string.settings_hide_media_toast, false)
|
||||
}
|
||||
|
||||
private fun setCacheLocation(path: String) {
|
||||
|
@ -41,19 +41,22 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||
import org.moire.ultrasonic.util.ContextMenuUtil
|
||||
import org.moire.ultrasonic.util.DownloadAction
|
||||
import org.moire.ultrasonic.util.DownloadUtil
|
||||
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.navigateToCurrent
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.SortOrder
|
||||
import org.moire.ultrasonic.view.ViewCapabilities
|
||||
import timber.log.Timber
|
||||
@ -86,7 +89,6 @@ open class TrackCollectionFragment(
|
||||
|
||||
internal val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
internal var cancellationToken: CancellationToken? = null
|
||||
|
||||
override val listModel: TrackCollectionModel by viewModels()
|
||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
@ -102,13 +104,12 @@ open class TrackCollectionFragment(
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
cancellationToken = CancellationToken()
|
||||
|
||||
albumButtons = view.findViewById(R.id.menu_album)
|
||||
|
||||
// Setup refresh handler
|
||||
refreshListView = view.findViewById(refreshListId)
|
||||
refreshListView?.setOnRefreshListener {
|
||||
swipeRefresh = view.findViewById(refreshListId)
|
||||
swipeRefresh?.setOnRefreshListener {
|
||||
handleRefresh()
|
||||
}
|
||||
|
||||
@ -211,19 +212,23 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
playNowButton?.setOnClickListener {
|
||||
playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true)
|
||||
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.CLEAR)
|
||||
}
|
||||
|
||||
playNextButton?.setOnClickListener {
|
||||
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true)
|
||||
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.AFTER_CURRENT)
|
||||
}
|
||||
|
||||
playLastButton!!.setOnClickListener {
|
||||
playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true)
|
||||
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.APPEND)
|
||||
}
|
||||
|
||||
pinButton?.setOnClickListener {
|
||||
downloadBackground(true)
|
||||
downloadSelectedOrAllTracks(true)
|
||||
}
|
||||
|
||||
downloadButton?.setOnClickListener {
|
||||
downloadSelectedOrAllTracks(false)
|
||||
}
|
||||
|
||||
unpinButton?.setOnClickListener {
|
||||
@ -231,26 +236,22 @@ open class TrackCollectionFragment(
|
||||
ConfirmationDialog.Builder(requireContext())
|
||||
.setMessage(R.string.common_unpin_selection_confirmation)
|
||||
.setPositiveButton(R.string.common_unpin) { _, _ ->
|
||||
unpin()
|
||||
unpinSelectedTracks()
|
||||
}.show()
|
||||
} else {
|
||||
unpin()
|
||||
unpinSelectedTracks()
|
||||
}
|
||||
}
|
||||
|
||||
downloadButton?.setOnClickListener {
|
||||
downloadBackground(false)
|
||||
}
|
||||
|
||||
deleteButton?.setOnClickListener {
|
||||
if (Settings.showConfirmationDialog) {
|
||||
ConfirmationDialog.Builder(requireContext())
|
||||
.setMessage(R.string.common_delete_selection_confirmation)
|
||||
.setPositiveButton(R.string.common_delete) { _, _ ->
|
||||
delete()
|
||||
deleteSelectedTracks()
|
||||
}.show()
|
||||
} else {
|
||||
delete()
|
||||
deleteSelectedTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -283,9 +284,9 @@ open class TrackCollectionFragment(
|
||||
return true
|
||||
} else if (item.itemId == R.id.menu_item_share) {
|
||||
shareHandler.createShare(
|
||||
this@TrackCollectionFragment, getSelectedTracks(),
|
||||
refreshListView, cancellationToken!!,
|
||||
navArgs.id
|
||||
fragment = this@TrackCollectionFragment,
|
||||
tracks = getSelectedOrAllTracks(),
|
||||
additionalId = navArgs.id
|
||||
)
|
||||
return true
|
||||
}
|
||||
@ -294,46 +295,10 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
rxBusSubscription.dispose()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun playNow(
|
||||
insertionMode: MediaPlayerManager.InsertionMode,
|
||||
selectedTracks: List<Track> = getSelectedTracks(),
|
||||
toast: Boolean = false
|
||||
) {
|
||||
if (selectedTracks.isNotEmpty()) {
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = selectedTracks,
|
||||
insertionMode = insertionMode,
|
||||
autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR),
|
||||
playlistName = null,
|
||||
fragment = this
|
||||
)
|
||||
} else {
|
||||
playAll(false, insertionMode)
|
||||
}
|
||||
|
||||
if (toast) {
|
||||
val stringInt = when (insertionMode) {
|
||||
MediaPlayerManager.InsertionMode.CLEAR ->
|
||||
R.plurals.n_songs_added_play_now
|
||||
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
|
||||
R.plurals.n_songs_added_after_current
|
||||
MediaPlayerManager.InsertionMode.APPEND ->
|
||||
R.plurals.n_songs_added_to_end
|
||||
}
|
||||
val msg = resources.getQuantityString(
|
||||
stringInt,
|
||||
selectedTracks.size,
|
||||
selectedTracks.size
|
||||
)
|
||||
Util.toast(requireContext(), msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the underlying list
|
||||
*/
|
||||
@ -364,31 +329,58 @@ open class TrackCollectionFragment(
|
||||
|
||||
// Need a valid id to recurse sub directories stuff
|
||||
if (hasSubFolders && navArgs.id != null) {
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = this,
|
||||
id = navArgs.id!!,
|
||||
insertionMode = insertionMode,
|
||||
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
|
||||
id = navArgs.id!!,
|
||||
shuffle = shuffle,
|
||||
isArtist = isArtist
|
||||
)
|
||||
} else {
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = getAllSongs(),
|
||||
mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs = getAllTracks(),
|
||||
insertionMode = insertionMode,
|
||||
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
|
||||
shuffle = shuffle,
|
||||
playlistName = navArgs.playlistName,
|
||||
fragment = this
|
||||
shuffle = shuffle
|
||||
)
|
||||
if (insertionMode == MediaPlayerManager.InsertionMode.CLEAR) {
|
||||
navigateToCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun unpinSelectedTracks() {
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
tracks = getSelectedTracks()
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getAllSongs(): List<Track> {
|
||||
return viewAdapter.getCurrentList().filter {
|
||||
it is Track && !it.isDirectory
|
||||
} as List<Track>
|
||||
private fun downloadSelectedOrAllTracks(save: Boolean) {
|
||||
DownloadUtil.justDownload(
|
||||
action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
tracks = getSelectedOrAllTracks()
|
||||
)
|
||||
}
|
||||
|
||||
private fun playSelectedOrAllTracks(
|
||||
insertionMode: MediaPlayerManager.InsertionMode
|
||||
) {
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = this,
|
||||
insertionMode = insertionMode,
|
||||
tracks = getSelectedOrAllTracks()
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteSelectedTracks() {
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.DELETE,
|
||||
fragment = this,
|
||||
tracks = getSelectedTracks()
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectAllOrNone() {
|
||||
@ -403,7 +395,7 @@ open class TrackCollectionFragment(
|
||||
|
||||
// Display toast: N tracks selected
|
||||
val toastResId = R.string.select_album_n_selected
|
||||
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
|
||||
toast(getString(toastResId, selectedCount.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@ -431,37 +423,6 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedTracks()) {
|
||||
var songs = tracks
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
songs = getAllSongs()
|
||||
}
|
||||
|
||||
val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD
|
||||
downloadHandler.justDownload(
|
||||
action = action,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
}
|
||||
|
||||
internal fun delete(songs: List<Track> = getSelectedTracks()) {
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DELETE,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
}
|
||||
|
||||
internal fun unpin(songs: List<Track> = getSelectedTracks()) {
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
}
|
||||
|
||||
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
|
||||
|
||||
Timber.i("Received list")
|
||||
@ -530,6 +491,19 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getAllTracks(): List<Track> {
|
||||
return viewAdapter.getCurrentList().filter {
|
||||
it is Track && !it.isDirectory
|
||||
} as List<Track>
|
||||
}
|
||||
|
||||
fun getSelectedOrAllTracks(): List<Track> {
|
||||
return getSelectedTracks().ifEmpty {
|
||||
getAllTracks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTitle(title: String?) {
|
||||
setTitle(this@TrackCollectionFragment, title)
|
||||
}
|
||||
@ -561,8 +535,10 @@ open class TrackCollectionFragment(
|
||||
val offset = navArgs.offset
|
||||
val refresh2 = navArgs.refresh || refresh
|
||||
|
||||
listModel.viewModelScope.launch(handler) {
|
||||
refreshListView?.isRefreshing = true
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
|
||||
if (playlistId != null) {
|
||||
setTitle(playlistName!!)
|
||||
@ -597,7 +573,7 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
refreshListView?.isRefreshing = false
|
||||
swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
return listModel.currentList
|
||||
}
|
||||
@ -606,48 +582,19 @@ open class TrackCollectionFragment(
|
||||
|
||||
private fun displayRandom() = (sortOrder == SortOrder.RANDOM) || navArgs.getRandom
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun onContextMenuItemSelected(
|
||||
menuItem: MenuItem,
|
||||
item: MusicDirectory.Child
|
||||
): Boolean {
|
||||
val songs = getClickedSong(item)
|
||||
|
||||
when (menuItem.itemId) {
|
||||
R.id.song_menu_play_now -> {
|
||||
playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true)
|
||||
}
|
||||
R.id.song_menu_pin -> {
|
||||
downloadBackground(true, songs)
|
||||
}
|
||||
R.id.song_menu_unpin -> {
|
||||
unpin(songs)
|
||||
}
|
||||
R.id.song_menu_download -> {
|
||||
downloadBackground(false, songs)
|
||||
}
|
||||
R.id.song_menu_share -> {
|
||||
if (item is Track) {
|
||||
shareHandler.createShare(
|
||||
this,
|
||||
tracks = listOf(item),
|
||||
swipe = refreshListView,
|
||||
cancellationToken = cancellationToken!!,
|
||||
additionalId = navArgs.id
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
return super.onContextItemSelected(menuItem)
|
||||
}
|
||||
}
|
||||
return true
|
||||
val tracks = getClickedSong(item)
|
||||
|
||||
return ContextMenuUtil.handleContextMenuTracks(
|
||||
menuItem = menuItem,
|
||||
tracks = tracks,
|
||||
mediaPlayerManager = mediaPlayerManager,
|
||||
fragment = this
|
||||
)
|
||||
}
|
||||
|
||||
private fun getClickedSong(item: MusicDirectory.Child): List<Track> {
|
||||
|
@ -0,0 +1,214 @@
|
||||
/*
|
||||
* ChatFragment.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.fragment.legacy
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import android.widget.ListAdapter
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import java.util.Locale
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.model.ChatViewModel
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.Util.isNullOrWhiteSpace
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.ChatAdapter
|
||||
|
||||
class ChatFragment : Fragment(), KoinComponent, RefreshableFragment {
|
||||
private lateinit var chatListView: ListView
|
||||
private lateinit var messageEditText: EditText
|
||||
private lateinit var sendButton: MaterialButton
|
||||
private var timer: Timer? = null
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private val chatViewModel: ChatViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.chat, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Add the ChatMenuProvider for creating the menu
|
||||
(requireActivity() as MenuHost).addMenuProvider(
|
||||
menuProvider,
|
||||
viewLifecycleOwner,
|
||||
Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
swipeRefresh = view.findViewById(R.id.chat_refresh)
|
||||
swipeRefresh?.isEnabled = false
|
||||
messageEditText = view.findViewById(R.id.chat_edittext)
|
||||
sendButton = view.findViewById(R.id.chat_send)
|
||||
sendButton.setOnClickListener { sendMessage() }
|
||||
chatListView = view.findViewById(R.id.chat_entries_list)
|
||||
chatListView.transcriptMode = ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL
|
||||
chatListView.isStackFromBottom = true
|
||||
val serverName = activeServerProvider.getActiveServer().name
|
||||
val userName = activeServerProvider.getActiveServer().userName
|
||||
val title = String.format(
|
||||
Locale.ROOT,
|
||||
"%s [%s@%s]",
|
||||
resources.getString(R.string.button_bar_chat),
|
||||
userName,
|
||||
serverName
|
||||
)
|
||||
setTitle(this, title)
|
||||
messageEditText.imeOptions = EditorInfo.IME_ACTION_SEND
|
||||
messageEditText.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {}
|
||||
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {}
|
||||
override fun afterTextChanged(editable: Editable) {
|
||||
sendButton.isEnabled = !isNullOrWhiteSpace(editable.toString())
|
||||
}
|
||||
})
|
||||
messageEditText.setOnEditorActionListener(
|
||||
OnEditorActionListener {
|
||||
_: TextView?,
|
||||
actionId: Int,
|
||||
event: KeyEvent ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND ||
|
||||
(actionId == EditorInfo.IME_NULL && event.action == KeyEvent.ACTION_DOWN)
|
||||
) {
|
||||
sendMessage()
|
||||
return@OnEditorActionListener true
|
||||
}
|
||||
false
|
||||
}
|
||||
)
|
||||
load()
|
||||
timerMethod()
|
||||
}
|
||||
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.chat, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
if (menuItem.itemId == R.id.menu_refresh) {
|
||||
load()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
chatViewModel.chatMessages.observe(viewLifecycleOwner) { messages ->
|
||||
if (!messages.isNullOrEmpty()) {
|
||||
val chatAdapter: ListAdapter = ChatAdapter(requireContext(), messages)
|
||||
chatListView.adapter = chatAdapter
|
||||
}
|
||||
}
|
||||
if (timer == null) {
|
||||
timerMethod()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
timer?.cancel()
|
||||
timer = null
|
||||
}
|
||||
|
||||
private fun timerMethod() {
|
||||
val refreshInterval = Settings.chatRefreshInterval
|
||||
if (refreshInterval > 0) {
|
||||
timer = Timer()
|
||||
timer?.schedule(
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
requireActivity().runOnUiThread { load() }
|
||||
}
|
||||
},
|
||||
refreshInterval.toLong(), refreshInterval.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage() {
|
||||
val text = messageEditText.text ?: return
|
||||
val message = text.toString()
|
||||
if (!isNullOrWhiteSpace(message)) {
|
||||
messageEditText.setText("")
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
musicService.addChatMessage(message)
|
||||
}
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
musicService.getChatMessages(chatViewModel.lastChatMessageTime)?.filterNotNull()
|
||||
}
|
||||
swipeRefresh?.isRefreshing = false
|
||||
if (!result.isNullOrEmpty()) {
|
||||
for (message in result) {
|
||||
if (message.time > chatViewModel.lastChatMessageTime) {
|
||||
chatViewModel.lastChatMessageTime = message.time
|
||||
}
|
||||
}
|
||||
chatViewModel.updateChatMessages(result.reversed())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -13,35 +13,34 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.Lyrics
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Displays the lyrics of a song
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class LyricsFragment : Fragment() {
|
||||
class LyricsFragment : Fragment(), RefreshableFragment {
|
||||
private var artistView: TextView? = null
|
||||
private var titleView: TextView? = null
|
||||
private var textView: TextView? = null
|
||||
private var swipe: SwipeRefreshLayout? = null
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
|
||||
private val navArgs by navArgs<LyricsFragmentArgs>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme(this.context)
|
||||
super.onCreate(savedInstanceState)
|
||||
applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -53,42 +52,34 @@ class LyricsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Timber.d("Lyrics set title")
|
||||
setTitle(this, R.string.download_menu_lyrics)
|
||||
swipe = view.findViewById(R.id.lyrics_refresh)
|
||||
swipe?.isEnabled = false
|
||||
swipeRefresh = view.findViewById(R.id.lyrics_refresh)
|
||||
swipeRefresh?.isEnabled = false
|
||||
artistView = view.findViewById(R.id.lyrics_artist)
|
||||
titleView = view.findViewById(R.id.lyrics_title)
|
||||
textView = view.findViewById(R.id.lyrics_text)
|
||||
load()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val task: BackgroundTask<Lyrics> = object : FragmentBackgroundTask<Lyrics>(
|
||||
activity, true, swipe, cancellationToken
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Lyrics {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
return musicService.getLyrics(navArgs.artist, navArgs.title)!!
|
||||
musicService.getLyrics(navArgs.artist, navArgs.title)!!
|
||||
}
|
||||
|
||||
override fun done(result: Lyrics) {
|
||||
swipeRefresh?.isRefreshing = false
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.artist != null) {
|
||||
artistView!!.text = result.artist
|
||||
titleView!!.text = result.title
|
||||
textView!!.text = result.text
|
||||
artistView?.text = result.artist
|
||||
titleView?.text = result.title
|
||||
textView?.text = result.text
|
||||
} else {
|
||||
artistView!!.setText(R.string.lyrics_nomatch)
|
||||
artistView?.setText(R.string.lyrics_nomatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.execute()
|
||||
}
|
||||
}
|
||||
|
@ -25,50 +25,46 @@ import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.util.Locale
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.Playlist
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.DownloadAction
|
||||
import org.moire.ultrasonic.util.DownloadUtil
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.LoadingTask
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Displays the playlists stored on the server
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
private var refreshPlaylistsListView: SwipeRefreshLayout? = null
|
||||
@Suppress("InstanceOfCheckForException")
|
||||
class PlaylistsFragment : ScopeFragment(), KoinScopeComponent, RefreshableFragment {
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private var playlistsListView: ListView? = null
|
||||
private var emptyTextView: View? = null
|
||||
private var playlistAdapter: ArrayAdapter<Playlist>? = null
|
||||
|
||||
private val downloadHandler by inject<DownloadHandler>()
|
||||
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme(this.context)
|
||||
super.onCreate(savedInstanceState)
|
||||
applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -80,17 +76,16 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
refreshPlaylistsListView = view.findViewById(R.id.select_playlist_refresh)
|
||||
swipeRefresh = view.findViewById(R.id.select_playlist_refresh)
|
||||
playlistsListView = view.findViewById(R.id.select_playlist_list)
|
||||
refreshPlaylistsListView!!.setOnRefreshListener { load(true) }
|
||||
swipeRefresh?.setOnRefreshListener { load(true) }
|
||||
emptyTextView = view.findViewById(R.id.select_playlist_empty)
|
||||
playlistsListView!!.setOnItemClickListener { parent, _, position, _ ->
|
||||
val (id1, name) = parent.getItemAtPosition(position) as Playlist
|
||||
playlistsListView?.setOnItemClickListener { parent, _, position, _ ->
|
||||
val (id, name) = parent.getItemAtPosition(position) as Playlist
|
||||
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
id = id1,
|
||||
playlistId = id1,
|
||||
id = id,
|
||||
playlistId = id,
|
||||
name = name,
|
||||
playlistName = name,
|
||||
)
|
||||
@ -101,32 +96,25 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
load(false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun load(refresh: Boolean) {
|
||||
val task: BackgroundTask<List<Playlist>> =
|
||||
object : FragmentBackgroundTask<List<Playlist>>(
|
||||
activity, true, refreshPlaylistsListView, cancellationToken
|
||||
) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): List<Playlist> {
|
||||
val musicService = getMusicService()
|
||||
val playlists = musicService.getPlaylists(refresh)
|
||||
if (!isOffline()) CacheCleaner().cleanPlaylists(playlists)
|
||||
return playlists
|
||||
}
|
||||
|
||||
override fun done(result: List<Playlist>) {
|
||||
playlistAdapter =
|
||||
ArrayAdapter(requireContext(), R.layout.list_item_generic, result)
|
||||
playlistsListView!!.adapter = playlistAdapter
|
||||
emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
val cacheCleaner: CacheCleaner by inject()
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
val playlists = musicService.getPlaylists(refresh)
|
||||
playlists
|
||||
}
|
||||
task.execute()
|
||||
swipeRefresh?.isRefreshing = false
|
||||
withContext(Dispatchers.Main) {
|
||||
playlistAdapter =
|
||||
ArrayAdapter(requireContext(), R.layout.list_item_generic, result)
|
||||
playlistsListView?.adapter = playlistAdapter
|
||||
emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
if (!isOffline()) cacheCleaner.cleanPlaylists(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContextMenu(menu: ContextMenu, view: View, menuInfo: ContextMenuInfo?) {
|
||||
@ -144,11 +132,11 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
|
||||
override fun onContextItemSelected(menuItem: MenuItem): Boolean {
|
||||
val info = menuItem.menuInfo as AdapterContextMenuInfo
|
||||
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
||||
val playlist = playlistsListView?.getItemAtPosition(info.position) as Playlist
|
||||
when (menuItem.itemId) {
|
||||
R.id.playlist_menu_pin -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.PIN,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
@ -157,8 +145,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_unpin -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
@ -167,8 +155,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_download -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
@ -215,58 +203,45 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
.setTitle(R.string.common_confirm).setMessage(
|
||||
resources.getString(R.string.delete_playlist, playlist.name)
|
||||
).setPositiveButton(R.string.common_ok) { _, _ ->
|
||||
object : LoadingTask<Any?>(activity, refreshPlaylistsListView, cancellationToken) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Any? {
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler(
|
||||
resources.getString(
|
||||
R.string.menu_deleted_playlist_error,
|
||||
playlist.name
|
||||
)
|
||||
)
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
musicService.deletePlaylist(playlist.id)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Any?) {
|
||||
playlistAdapter!!.remove(playlist)
|
||||
playlistAdapter!!.notifyDataSetChanged()
|
||||
withContext(Dispatchers.Main) {
|
||||
playlistAdapter?.remove(playlist)
|
||||
playlistAdapter?.notifyDataSetChanged()
|
||||
toast(
|
||||
context,
|
||||
resources.getString(R.string.menu_deleted_playlist, playlist.name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(error: Throwable) {
|
||||
val msg: String =
|
||||
if (error is OfflineException || error is ApiNotSupportedException)
|
||||
getErrorMessage(
|
||||
error
|
||||
) else String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(
|
||||
R.string.menu_deleted_playlist_error,
|
||||
playlist.name
|
||||
),
|
||||
getErrorMessage(error)
|
||||
)
|
||||
toast(context, msg, false)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}.setNegativeButton(R.string.common_cancel, null).show()
|
||||
}
|
||||
|
||||
private fun displayPlaylistInfo(playlist: Playlist) {
|
||||
val textView = TextView(context)
|
||||
val textView = TextView(requireContext())
|
||||
textView.setPadding(5, 5, 5, 5)
|
||||
val message: Spannable = SpannableString(
|
||||
"""
|
||||
Owner: ${playlist.owner}
|
||||
Comments: ${playlist.comment}
|
||||
Song Count: ${playlist.songCount}
|
||||
Owner: ${playlist.owner}
|
||||
Comments: ${playlist.comment}
|
||||
Song Count: ${playlist.songCount}
|
||||
""".trimIndent() +
|
||||
if (playlist.public == null) "" else """
|
||||
|
||||
Public: ${playlist.public}
|
||||
|
||||
Public: ${playlist.public}
|
||||
""".trimIndent() + """
|
||||
|
||||
Creation Date: ${playlist.created.replace('T', ' ')}
|
||||
|
||||
Creation Date: ${playlist.created.replace('T', ' ')}
|
||||
""".trimIndent()
|
||||
)
|
||||
Linkify.addLinks(message, Linkify.WEB_URLS)
|
||||
@ -295,43 +270,31 @@ class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
alertDialog.setTitle(R.string.playlist_update_info)
|
||||
alertDialog.setView(dialogView)
|
||||
alertDialog.setPositiveButton(R.string.common_ok) { _, _ ->
|
||||
object : LoadingTask<Any?>(activity, refreshPlaylistsListView, cancellationToken) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Any? {
|
||||
val nameBoxText = nameBox.text
|
||||
val commentBoxText = commentBox.text
|
||||
val name = nameBoxText?.toString()
|
||||
val comment = commentBoxText?.toString()
|
||||
val musicService = getMusicService()
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler(
|
||||
resources.getString(
|
||||
R.string.playlist_updated_info_error,
|
||||
playlist.name
|
||||
)
|
||||
)
|
||||
) {
|
||||
val nameBoxText = nameBox.text
|
||||
val commentBoxText = commentBox.text
|
||||
val name = nameBoxText?.toString()
|
||||
val comment = commentBoxText?.toString()
|
||||
val musicService = getMusicService()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
musicService.updatePlaylist(playlist.id, name, comment, publicBox.isChecked)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Any?) {
|
||||
withContext(Dispatchers.Main) {
|
||||
load(true)
|
||||
toast(
|
||||
context,
|
||||
resources.getString(R.string.playlist_updated_info, playlist.name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(error: Throwable) {
|
||||
val msg: String =
|
||||
if (error is OfflineException || error is ApiNotSupportedException)
|
||||
getErrorMessage(
|
||||
error
|
||||
) else String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(
|
||||
R.string.playlist_updated_info_error,
|
||||
playlist.name
|
||||
),
|
||||
getErrorMessage(error)
|
||||
)
|
||||
toast(context, msg, false)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}
|
||||
alertDialog.setNegativeButton(R.string.common_cancel, null)
|
||||
alertDialog.show()
|
||||
|
@ -13,35 +13,33 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.PodcastsChannel
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Displays the podcasts available on the server
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
* TODO: Use Coroutines
|
||||
*/
|
||||
class PodcastFragment : Fragment() {
|
||||
|
||||
class PodcastFragment : ScopeFragment(), RefreshableFragment {
|
||||
private var emptyTextView: View? = null
|
||||
var channelItemsListView: ListView? = null
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
private var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private var channelItemsListView: ListView? = null
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme(this.context)
|
||||
super.onCreate(savedInstanceState)
|
||||
applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -54,45 +52,33 @@ class PodcastFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
cancellationToken = CancellationToken()
|
||||
swipeRefresh = view.findViewById(R.id.podcasts_refresh)
|
||||
swipeRefresh!!.setOnRefreshListener { load(true) }
|
||||
swipeRefresh?.setOnRefreshListener { load(true) }
|
||||
setTitle(this, R.string.podcasts_label)
|
||||
emptyTextView = view.findViewById(R.id.select_podcasts_empty)
|
||||
channelItemsListView = view.findViewById(R.id.podcasts_channels_items_list)
|
||||
channelItemsListView!!.setOnItemClickListener { parent, _, position, _ ->
|
||||
val (id) = parent.getItemAtPosition(position) as PodcastsChannel
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
podcastChannelId = id
|
||||
)
|
||||
|
||||
channelItemsListView?.setOnItemClickListener { parent, _, position, _ ->
|
||||
val id = (parent.getItemAtPosition(position) as PodcastsChannel).id
|
||||
val action = NavigationGraphDirections.toTrackCollection(podcastChannelId = id)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
load(false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun load(refresh: Boolean) {
|
||||
val task: BackgroundTask<List<PodcastsChannel>> =
|
||||
object : FragmentBackgroundTask<List<PodcastsChannel>>(
|
||||
activity, true, swipeRefresh, cancellationToken
|
||||
) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): List<PodcastsChannel> {
|
||||
val musicService = getMusicService()
|
||||
return musicService.getPodcastsChannels(refresh)
|
||||
}
|
||||
|
||||
override fun done(result: List<PodcastsChannel>) {
|
||||
channelItemsListView!!.adapter =
|
||||
ArrayAdapter(requireContext(), R.layout.list_item_generic, result)
|
||||
emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
musicService.getPodcastsChannels(refresh)
|
||||
}
|
||||
task.execute()
|
||||
swipeRefresh?.isRefreshing = false
|
||||
withContext(Dispatchers.Main) {
|
||||
channelItemsListView?.adapter =
|
||||
ArrayAdapter(requireContext(), R.layout.list_item_generic, result)
|
||||
emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,3 @@
|
||||
/*
|
||||
* SelectGenreFragment.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.fragment.legacy
|
||||
|
||||
import android.os.Bundle
|
||||
@ -15,35 +8,34 @@ import android.widget.AdapterView
|
||||
import android.widget.ListView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.Genre
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.Settings.maxSongs
|
||||
import org.moire.ultrasonic.util.Util.applyTheme
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.GenreAdapter
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Displays the available genres in the media library
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class SelectGenreFragment : Fragment() {
|
||||
private var refreshGenreListView: SwipeRefreshLayout? = null
|
||||
class SelectGenreFragment : Fragment(), RefreshableFragment {
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private var genreListView: ListView? = null
|
||||
private var emptyView: View? = null
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme(this.context)
|
||||
super.onCreate(savedInstanceState)
|
||||
applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -55,15 +47,15 @@ class SelectGenreFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
refreshGenreListView = view.findViewById(R.id.select_genre_refresh)
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
swipeRefresh = view.findViewById(R.id.select_genre_refresh)
|
||||
genreListView = view.findViewById(R.id.select_genre_list)
|
||||
refreshGenreListView!!.setOnRefreshListener { load(true) }
|
||||
swipeRefresh?.setOnRefreshListener { load(true) }
|
||||
|
||||
genreListView!!.setOnItemClickListener { parent: AdapterView<*>,
|
||||
_: View?,
|
||||
position: Int,
|
||||
_: Long ->
|
||||
genreListView?.setOnItemClickListener {
|
||||
parent: AdapterView<*>, _: View?,
|
||||
position: Int, _: Long
|
||||
->
|
||||
val genre = parent.getItemAtPosition(position) as Genre
|
||||
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
@ -79,34 +71,19 @@ class SelectGenreFragment : Fragment() {
|
||||
load(false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
// TODO: Migrate to Coroutines
|
||||
private fun load(refresh: Boolean) {
|
||||
val task: BackgroundTask<List<Genre>> = object : FragmentBackgroundTask<List<Genre>>(
|
||||
activity, true, refreshGenreListView, cancellationToken
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
override fun doInBackground(): List<Genre> {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = getMusicService()
|
||||
var genres: List<Genre> = ArrayList()
|
||||
try {
|
||||
genres = musicService.getGenres(refresh)
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "Failed to load genres")
|
||||
}
|
||||
return genres
|
||||
musicService.getGenres(refresh)
|
||||
}
|
||||
|
||||
override fun done(result: List<Genre>) {
|
||||
emptyView!!.isVisible = result.isEmpty()
|
||||
if (context != null) {
|
||||
genreListView!!.adapter = GenreAdapter(context!!, result)
|
||||
}
|
||||
swipeRefresh?.isRefreshing = false
|
||||
withContext(Dispatchers.Main) {
|
||||
emptyView?.isVisible = result.isEmpty()
|
||||
genreListView?.adapter = GenreAdapter(requireContext(), result)
|
||||
}
|
||||
}
|
||||
task.execute()
|
||||
}
|
||||
}
|
||||
|
@ -24,28 +24,29 @@ import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.util.Locale
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.androidx.scope.ScopeFragment
|
||||
import org.koin.core.component.KoinScopeComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
import org.moire.ultrasonic.domain.Share
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.LoadingTask
|
||||
import org.moire.ultrasonic.util.DownloadAction
|
||||
import org.moire.ultrasonic.util.DownloadUtil
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.TimeSpanPicker
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.launchWithToast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.ShareAdapter
|
||||
|
||||
/**
|
||||
@ -53,16 +54,16 @@ import org.moire.ultrasonic.view.ShareAdapter
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class SharesFragment : Fragment(), KoinComponent {
|
||||
private var refreshSharesListView: SwipeRefreshLayout? = null
|
||||
class SharesFragment : ScopeFragment(), KoinScopeComponent, RefreshableFragment {
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private var sharesListView: ListView? = null
|
||||
private var emptyTextView: View? = null
|
||||
private var shareAdapter: ShareAdapter? = null
|
||||
private val downloadHandler: DownloadHandler by inject()
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Util.applyTheme(this.context)
|
||||
super.onCreate(savedInstanceState)
|
||||
Util.applyTheme(requireContext())
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -74,48 +75,41 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
refreshSharesListView = view.findViewById(R.id.select_share_refresh)
|
||||
swipeRefresh = view.findViewById(R.id.select_share_refresh)
|
||||
sharesListView = view.findViewById(R.id.select_share_list)
|
||||
refreshSharesListView!!.setOnRefreshListener { load(true) }
|
||||
swipeRefresh!!.setOnRefreshListener { load(true) }
|
||||
emptyTextView = view.findViewById(R.id.select_share_empty)
|
||||
sharesListView!!.onItemClickListener =
|
||||
AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||
val share = parent.getItemAtPosition(position) as Share
|
||||
sharesListView!!.onItemClickListener = AdapterView.OnItemClickListener {
|
||||
parent, _,
|
||||
position, _ ->
|
||||
val share = parent.getItemAtPosition(position) as Share
|
||||
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
shareId = share.id,
|
||||
shareName = share.name
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
shareId = share.id,
|
||||
shareName = share.name
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
registerForContextMenu(sharesListView!!)
|
||||
FragmentTitle.setTitle(this, R.string.button_bar_shares)
|
||||
load(false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
cancellationToken!!.cancel()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun load(refresh: Boolean) {
|
||||
val task: BackgroundTask<List<Share>> = object : FragmentBackgroundTask<List<Share>>(
|
||||
activity, true, refreshSharesListView, cancellationToken
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): List<Share> {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
return musicService.getShares(refresh)
|
||||
musicService.getShares(refresh)
|
||||
}
|
||||
|
||||
override fun done(result: List<Share>) {
|
||||
swipeRefresh?.isRefreshing = false
|
||||
withContext(Dispatchers.Main) {
|
||||
shareAdapter = ShareAdapter(requireContext(), result)
|
||||
sharesListView?.adapter = shareAdapter
|
||||
emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
task.execute()
|
||||
}
|
||||
|
||||
override fun onCreateContextMenu(
|
||||
@ -133,8 +127,8 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
val share = sharesListView!!.getItemAtPosition(info.position) as Share
|
||||
when (menuItem.itemId) {
|
||||
R.id.share_menu_pin -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.PIN,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
@ -143,8 +137,8 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
)
|
||||
}
|
||||
R.id.share_menu_unpin -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
@ -153,8 +147,8 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
)
|
||||
}
|
||||
R.id.share_menu_download -> {
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
@ -163,22 +157,20 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_now -> {
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
autoPlay = true,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
shuffle = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_shuffled -> {
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
autoPlay = true,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
shuffle = true,
|
||||
)
|
||||
}
|
||||
@ -203,46 +195,34 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
.setTitle(R.string.common_confirm).setMessage(
|
||||
resources.getString(R.string.delete_playlist, share.name)
|
||||
).setPositiveButton(R.string.common_ok) { _, _ ->
|
||||
object : LoadingTask<Any?>(activity, refreshSharesListView, cancellationToken) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Any? {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
musicService.deleteShare(share.id)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Any?) {
|
||||
shareAdapter!!.remove(share)
|
||||
shareAdapter!!.notifyDataSetChanged()
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getString(R.string.menu_deleted_share, share.name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(error: Throwable) {
|
||||
val msg: String =
|
||||
if (error is OfflineException || error is ApiNotSupportedException) {
|
||||
getErrorMessage(
|
||||
error
|
||||
)
|
||||
} else {
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(
|
||||
R.string.menu_deleted_share_error,
|
||||
share.name
|
||||
),
|
||||
getErrorMessage(error)
|
||||
)
|
||||
}
|
||||
Util.toast(context, msg, false)
|
||||
}
|
||||
}.execute()
|
||||
deleteShareOnServer(share)
|
||||
}.setNegativeButton(R.string.common_cancel, null).show()
|
||||
}
|
||||
|
||||
private fun deleteShareOnServer(share: Share) {
|
||||
viewLifecycleOwner.lifecycleScope.launch(
|
||||
toastingExceptionHandler(
|
||||
resources.getString(
|
||||
R.string.menu_deleted_share_error,
|
||||
share.name
|
||||
)
|
||||
)
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
musicService.deleteShare(share.id)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
shareAdapter?.remove(share)
|
||||
shareAdapter?.notifyDataSetChanged()
|
||||
toast(
|
||||
resources.getString(R.string.menu_deleted_share, share.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayShareInfo(share: Share) {
|
||||
val textView = TextView(context)
|
||||
textView.setPadding(5, 5, 5, 5)
|
||||
@ -257,18 +237,18 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
(
|
||||
if (share.created == null) "" else """
|
||||
|
||||
Creation Date: ${share.created!!.replace('T', ' ')}
|
||||
Creation Date: ${share.created!!.replace('T', ' ')}
|
||||
""".trimIndent()
|
||||
) +
|
||||
(
|
||||
if (share.lastVisited == null) "" else """
|
||||
|
||||
Last Visited Date: ${share.lastVisited!!.replace('T', ' ')}
|
||||
Last Visited Date: ${share.lastVisited!!.replace('T', ' ')}
|
||||
""".trimIndent()
|
||||
) +
|
||||
if (share.expires == null) "" else """
|
||||
|
||||
Expiration Date: ${share.expires!!.replace('T', ' ')}
|
||||
Expiration Date: ${share.expires!!.replace('T', ' ')}
|
||||
""".trimIndent()
|
||||
)
|
||||
Linkify.addLinks(message, Linkify.WEB_URLS)
|
||||
@ -299,50 +279,31 @@ class SharesFragment : Fragment(), KoinComponent {
|
||||
alertDialog.setTitle(R.string.playlist_update_info)
|
||||
alertDialog.setView(dialogView)
|
||||
alertDialog.setPositiveButton(R.string.common_ok) { _, _ ->
|
||||
object : LoadingTask<Any?>(activity, refreshSharesListView, cancellationToken) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Any? {
|
||||
var millis = timeSpanPicker.getTimeSpan()
|
||||
if (millis > 0) {
|
||||
millis += System.currentTimeMillis()
|
||||
}
|
||||
val shareDescriptionText = shareDescription.text
|
||||
val description = shareDescriptionText?.toString()
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
musicService.updateShare(share.id, description, millis)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun done(result: Any?) {
|
||||
load(true)
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getString(R.string.playlist_updated_info, share.name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(error: Throwable) {
|
||||
val msg: String =
|
||||
if (error is OfflineException || error is ApiNotSupportedException) {
|
||||
getErrorMessage(
|
||||
error
|
||||
)
|
||||
} else {
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(
|
||||
R.string.playlist_updated_info_error,
|
||||
share.name
|
||||
),
|
||||
getErrorMessage(error)
|
||||
)
|
||||
}
|
||||
Util.toast(context, msg, false)
|
||||
}
|
||||
}.execute()
|
||||
var millis = timeSpanPicker.getTimeSpan()
|
||||
if (millis > 0) {
|
||||
millis += System.currentTimeMillis()
|
||||
}
|
||||
updateShareOnServer(millis, shareDescription.text.toString(), share)
|
||||
}
|
||||
alertDialog.setNegativeButton(R.string.common_cancel, null)
|
||||
alertDialog.show()
|
||||
}
|
||||
|
||||
private fun updateShareOnServer(
|
||||
millis: Long,
|
||||
description: String,
|
||||
share: Share
|
||||
) {
|
||||
launchWithToast {
|
||||
withContext(Dispatchers.IO) {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
musicService.updateShare(share.id, description, millis)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
load(true)
|
||||
resources.getString(R.string.playlist_updated_info, share.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -190,17 +190,18 @@ class ImageLoader(
|
||||
if (artist.coverArt == null) return
|
||||
val key = FileUtil.getArtistArtKey(artist.name, false)
|
||||
val file = FileUtil.getAlbumArtFile(key)
|
||||
cacheCoverArt(artist.coverArt!!, file)
|
||||
downloadCoverArt(artist.coverArt!!, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a cover art file of a Track and cache it on disk
|
||||
*/
|
||||
fun cacheCoverArt(track: Track) {
|
||||
cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
|
||||
fun downloadCoverArt(track: Track) {
|
||||
if (track.coverArt == null) return
|
||||
downloadCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
|
||||
}
|
||||
|
||||
fun cacheCoverArt(id: String, file: String) = launch {
|
||||
fun downloadCoverArt(id: String, file: String) = launch {
|
||||
if (id.isBlank()) return@launch
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
|
@ -0,0 +1,31 @@
|
||||
/*
|
||||
* ChatViewModel.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.model
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.moire.ultrasonic.domain.ChatMessage
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
// MutableLiveData to store chat messages
|
||||
private val _chatMessages = MutableLiveData<List<ChatMessage>>()
|
||||
|
||||
// LiveData to observe chat messages
|
||||
val chatMessages: LiveData<List<ChatMessage>>
|
||||
get() = _chatMessages
|
||||
|
||||
// Last chat message time
|
||||
var lastChatMessageTime: Long = 0
|
||||
|
||||
// Function to update chat messages
|
||||
fun updateChatMessages(messages: List<ChatMessage>) {
|
||||
val updatedMessages = _chatMessages.value.orEmpty() + messages
|
||||
_chatMessages.postValue(updatedMessages)
|
||||
}
|
||||
}
|
@ -14,17 +14,18 @@ class SearchListModel(application: Application) : GenericListModel(application)
|
||||
|
||||
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData()
|
||||
|
||||
suspend fun search(query: String) {
|
||||
suspend fun search(query: String): SearchResult? {
|
||||
val maxArtists = Settings.maxArtists
|
||||
val maxAlbums = Settings.maxAlbums
|
||||
val maxSongs = Settings.maxSongs
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val criteria = SearchCriteria(query, maxArtists, maxAlbums, maxSongs)
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val result = service.search(criteria)
|
||||
|
||||
if (result != null) searchResult.postValue(result)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,8 +56,11 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
val albumArtFile = FileUtil.getAlbumArtFile(parts[1])
|
||||
Timber.d("AlbumArtContentProvider openFile id: %s; file: %s", parts[0], albumArtFile)
|
||||
|
||||
// TODO: Check if the dependency on the image loader could be removed.
|
||||
// TODO: This method can be called outside of our regular lifecycle, where Koin might not exist yet
|
||||
imageLoaderProvider.executeOn {
|
||||
it.cacheCoverArt(parts[0], albumArtFile)
|
||||
it.downloadCoverArt(parts[0], albumArtFile)
|
||||
}
|
||||
val file = File(albumArtFile)
|
||||
if (!file.exists()) return null
|
||||
|
@ -23,7 +23,7 @@ import android.widget.RemoteViews
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
||||
import org.moire.ultrasonic.receiver.UltrasonicIntentReceiver
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import timber.log.Timber
|
||||
|
||||
@ -233,7 +233,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
// Emulate media button clicks.
|
||||
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_KEY_EVENT,
|
||||
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
|
||||
@ -241,12 +241,12 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
|
||||
flags = 0
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// needed starting Android 12 (S = 31)
|
||||
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||
flags = PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
pendingIntent = PendingIntent.getBroadcast(context, 11, intent, flags)
|
||||
views.setOnClickPendingIntent(R.id.control_play, pendingIntent)
|
||||
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_KEY_EVENT,
|
||||
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
|
||||
@ -254,7 +254,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
|
||||
pendingIntent = PendingIntent.getBroadcast(context, 12, intent, flags)
|
||||
views.setOnClickPendingIntent(R.id.control_next, pendingIntent)
|
||||
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_KEY_EVENT,
|
||||
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||
|
@ -1,60 +0,0 @@
|
||||
/*
|
||||
* MediaButtonIntentReceiver.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import java.lang.Exception
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is used to receive commands from the widget
|
||||
*/
|
||||
class MediaButtonIntentReceiver : BroadcastReceiver(), KoinComponent {
|
||||
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val intentAction = intent.action
|
||||
|
||||
// If media button are turned off and we received a media button, exit
|
||||
if (!Settings.mediaButtonsEnabled && Intent.ACTION_MEDIA_BUTTON == intentAction) return
|
||||
|
||||
// Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets
|
||||
if (Intent.ACTION_MEDIA_BUTTON != intentAction &&
|
||||
Constants.CMD_PROCESS_KEYCODE != intentAction
|
||||
) return
|
||||
val extras = intent.extras ?: return
|
||||
|
||||
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
extras.getParcelable(Intent.EXTRA_KEY_EVENT, Parcelable::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
extras.get(Intent.EXTRA_KEY_EVENT) as Parcelable?
|
||||
}
|
||||
|
||||
Timber.i("Got MEDIA_BUTTON key event: %s", event)
|
||||
try {
|
||||
val serviceIntent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||
serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event)
|
||||
lifecycleSupport.receiveIntent(serviceIntent)
|
||||
if (isOrderedBroadcast) {
|
||||
abortBroadcast()
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
}
|
@ -10,20 +10,19 @@ package org.moire.ultrasonic.receiver
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import timber.log.Timber
|
||||
|
||||
class UltrasonicIntentReceiver : BroadcastReceiver() {
|
||||
private val lifecycleSupport = inject<MediaPlayerLifecycleSupport>(
|
||||
MediaPlayerLifecycleSupport::class.java
|
||||
)
|
||||
class UltrasonicIntentReceiver : BroadcastReceiver(), KoinComponent {
|
||||
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val intentAction = intent.action
|
||||
Timber.i("Received Ultrasonic Intent: %s", intentAction)
|
||||
try {
|
||||
lifecycleSupport.value.receiveIntent(intent)
|
||||
lifecycleSupport.receiveIntent(intent)
|
||||
if (isOrderedBroadcast) {
|
||||
abortBroadcast()
|
||||
}
|
||||
|
@ -315,6 +315,10 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
|
||||
return musicService.getStreamUrl(id, maxBitRate, format)
|
||||
}
|
||||
|
||||
override fun isJukeboxAvailable(): Boolean {
|
||||
return musicService.isJukeboxAvailable()
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus {
|
||||
return musicService.updateJukeboxPlaylist(ids)
|
||||
|
@ -29,8 +29,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
@ -61,6 +63,7 @@ private const val CHECK_INTERVAL = 5000L
|
||||
class DownloadService : Service(), KoinComponent {
|
||||
private var scope: CoroutineScope? = null
|
||||
private val storageMonitor: ExternalStorageMonitor by inject()
|
||||
private val cacheCleaner: CacheCleaner by inject()
|
||||
private val binder: IBinder = SimpleServiceBinder(this)
|
||||
|
||||
private var isInForeground = false
|
||||
@ -153,7 +156,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
|
||||
// Stop Executor service when done downloading
|
||||
if (activeDownloads.isEmpty()) {
|
||||
CacheCleaner().cleanSpace()
|
||||
cacheCleaner.cleanSpace()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@ -279,7 +282,17 @@ class DownloadService : Service(), KoinComponent {
|
||||
updateSaveFlag: Boolean = false
|
||||
) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloadAsync(tracks, save, isHighPriority, updateSaveFlag)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadAsync(
|
||||
tracks: List<Track>,
|
||||
save: Boolean = false,
|
||||
isHighPriority: Boolean = false,
|
||||
updateSaveFlag: Boolean = false
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Remove tracks which are already downloaded and update the save flag
|
||||
// if needed
|
||||
var filteredTracks = if (updateSaveFlag) {
|
||||
@ -384,8 +397,14 @@ class DownloadService : Service(), KoinComponent {
|
||||
failedList.clear()
|
||||
}
|
||||
|
||||
fun delete(track: Track) {
|
||||
private fun delete(track: Track) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
deleteAsync(track)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteAsync(track: Track) {
|
||||
withContext(Dispatchers.IO) {
|
||||
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
|
||||
failedList[track.id]?.let { downloadQueue.remove(it) }
|
||||
cancelDownload(track)
|
||||
@ -394,7 +413,8 @@ class DownloadService : Service(), KoinComponent {
|
||||
Storage.delete(track.getCompleteFile())
|
||||
Storage.delete(track.getPinnedFile())
|
||||
postState(track, DownloadState.IDLE)
|
||||
CacheCleaner().cleanDatabaseSelective(track)
|
||||
val cacheCleaner: CacheCleaner by inject(CacheCleaner::class.java)
|
||||
cacheCleaner.cleanDatabaseSelective(track)
|
||||
Util.scanMedia(track.getPinnedFile())
|
||||
}
|
||||
}
|
||||
@ -409,22 +429,38 @@ class DownloadService : Service(), KoinComponent {
|
||||
tracks.forEach(::delete)
|
||||
}
|
||||
|
||||
fun unpin(track: Track) {
|
||||
// Update Pinned flag of items in progress
|
||||
downloadQueue.get(track.id)?.pinned = false
|
||||
activeDownloads[track.id]?.downloadTrack?.pinned = false
|
||||
failedList[track.id]?.pinned = false
|
||||
suspend fun unpinAsync(tracks: List<Track>) {
|
||||
tracks.forEach { unpinAsync(it) }
|
||||
}
|
||||
|
||||
val pinnedFile = track.getPinnedFile()
|
||||
if (!Storage.isPathExists(pinnedFile)) return
|
||||
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
|
||||
try {
|
||||
Storage.rename(file, track.getCompleteFile())
|
||||
} catch (ignored: FileAlreadyExistsException) {
|
||||
// Play console has revealed a crash when for some reason both files exist
|
||||
Storage.delete(file.path)
|
||||
suspend fun deleteAsync(tracks: List<Track>) {
|
||||
tracks.forEach { deleteAsync(it) }
|
||||
}
|
||||
|
||||
private fun unpin(track: Track) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
unpinAsync(track)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unpinAsync(track: Track) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Update Pinned flag of items in progress
|
||||
downloadQueue.get(track.id)?.pinned = false
|
||||
activeDownloads[track.id]?.downloadTrack?.pinned = false
|
||||
failedList[track.id]?.pinned = false
|
||||
|
||||
val pinnedFile = track.getPinnedFile()
|
||||
if (!Storage.isPathExists(pinnedFile)) return@withContext
|
||||
val file = Storage.getFromPath(track.getPinnedFile()) ?: return@withContext
|
||||
try {
|
||||
Storage.rename(file, track.getCompleteFile())
|
||||
} catch (ignored: FileAlreadyExistsException) {
|
||||
// Play console has revealed a crash when for some reason both files exist
|
||||
Storage.delete(file.path)
|
||||
}
|
||||
postState(track, DownloadState.DONE)
|
||||
}
|
||||
postState(track, DownloadState.DONE)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
@ -474,12 +510,16 @@ class DownloadService : Service(), KoinComponent {
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
val context = UApp.applicationContext()
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
try {
|
||||
val context = UApp.applicationContext()
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.w(e, "Failed to start download service: the app is in the background")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -256,7 +256,7 @@ class DownloadTask(
|
||||
|
||||
// Download the largest size that we can display in the UI
|
||||
imageLoaderProvider.executeOn { imageLoader ->
|
||||
imageLoader.cacheCoverArt(this)
|
||||
imageLoader.downloadCoverArt(this@cacheMetadataAndArtwork)
|
||||
// Cache small copies of the Artist picture
|
||||
directArtist?.let { imageLoader.cacheArtistPicture(it) }
|
||||
compilationArtist?.let { imageLoader.cacheArtistPicture(it) }
|
||||
|
@ -1,13 +1,12 @@
|
||||
/*
|
||||
* AutoMediaBrowserCallback.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
* MediaLibrarySessionCallback.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.playback
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.car.app.connection.CarConnection
|
||||
@ -32,11 +31,14 @@ import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.guava.future
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
@ -48,8 +50,6 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.RatingManager
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.buildMediaItem
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
@ -98,10 +98,12 @@ const val PLAY_COMMAND = "play "
|
||||
* MediaBrowserService implementation for e.g. Android Auto
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
||||
class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
||||
class MediaLibrarySessionCallback :
|
||||
MediaLibraryService.MediaLibrarySession.Callback,
|
||||
KoinComponent {
|
||||
|
||||
private val applicationContext: Context by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val playbackStateSerializer: PlaybackStateSerializer by inject()
|
||||
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
@ -243,6 +245,25 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
override fun onPlaybackResumption(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val result = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||
serviceScope.launch {
|
||||
val state = playbackStateSerializer.deserializeNow()
|
||||
if (state != null) {
|
||||
result.set(state.toMediaItemsWithStartPosition())
|
||||
withContext(Dispatchers.Main) {
|
||||
mediaSession.player.shuffleModeEnabled = state.shufflePlay
|
||||
mediaSession.player.repeatMode = state.repeatMode
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun configureRepeatMode(player: Player) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Timber.d("Car app library available, observing CarConnection")
|
||||
@ -251,7 +272,7 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac
|
||||
|
||||
var lastCarConnectionType = -1
|
||||
|
||||
CarConnection(applicationContext).type.observeForever {
|
||||
CarConnection(UApp.applicationContext()).type.observeForever {
|
||||
if (lastCarConnectionType == it)
|
||||
return@observeForever
|
||||
|
@ -16,8 +16,6 @@ import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
@ -29,11 +27,13 @@ import timber.log.Timber
|
||||
/**
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*/
|
||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
class MediaPlayerLifecycleSupport(
|
||||
val mediaPlayerManager: MediaPlayerManager,
|
||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||
val imageLoaderProvider: ImageLoaderProvider,
|
||||
private val cacheCleaner: CacheCleaner
|
||||
) : KoinComponent {
|
||||
private lateinit var ratingManager: RatingManager
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val mediaPlayerManager by inject<MediaPlayerManager>()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
|
||||
private var created = false
|
||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||
@ -70,7 +70,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
registerHeadsetReceiver()
|
||||
|
||||
CacheCleaner().clean()
|
||||
cacheCleaner.clean()
|
||||
created = true
|
||||
ratingManager = RatingManager.instance
|
||||
Timber.i("LifecycleSupport created")
|
||||
@ -78,15 +78,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
private fun restoreLastSession(autoPlay: Boolean, afterRestore: Runnable?) {
|
||||
playbackStateSerializer.deserialize {
|
||||
if (it == null) return@deserialize null
|
||||
Timber.i("Restoring %s songs", it.songs.size)
|
||||
|
||||
Timber.i("Restoring %s songs", it!!.songs.size)
|
||||
|
||||
mediaPlayerManager.restore(
|
||||
it,
|
||||
autoPlay,
|
||||
false
|
||||
)
|
||||
|
||||
mediaPlayerManager.restore(it, autoPlay)
|
||||
afterRestore?.run()
|
||||
}
|
||||
}
|
||||
@ -99,7 +94,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
applicationContext().unregisterReceiver(headsetEventReceiver)
|
||||
|
||||
imageLoaderProvider.clearImageLoader()
|
||||
UApp.instance!!.shutdownKoin()
|
||||
|
||||
created = false
|
||||
Timber.i("LifecycleSupport destroyed")
|
||||
|
@ -7,10 +7,10 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
@ -29,17 +29,20 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.playback.PlaybackService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.DownloadUtil
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.navigateToCurrent
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import org.moire.ultrasonic.util.launchWithToast
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
import org.moire.ultrasonic.util.toTrack
|
||||
import timber.log.Timber
|
||||
@ -56,12 +59,9 @@ private const val VOLUME_DELTA = 0.05f
|
||||
@Suppress("TooManyFunctions")
|
||||
class MediaPlayerManager(
|
||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
val context: Context
|
||||
private val externalStorageMonitor: ExternalStorageMonitor
|
||||
) : KoinComponent {
|
||||
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private var created = false
|
||||
var suggestedPlaylistName: String? = null
|
||||
var keepScreenOn = false
|
||||
@ -73,8 +73,10 @@ class MediaPlayerManager(
|
||||
|
||||
private var mainScope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
private var sessionToken =
|
||||
SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
private var sessionToken = SessionToken(
|
||||
UApp.applicationContext(),
|
||||
ComponentName(UApp.applicationContext(), PlaybackService::class.java)
|
||||
)
|
||||
|
||||
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
|
||||
|
||||
@ -145,12 +147,11 @@ class MediaPlayerManager(
|
||||
Timber.w(error.toString())
|
||||
if (!isJukeboxEnabled) return
|
||||
|
||||
val context = UApp.applicationContext()
|
||||
mainScope.launch {
|
||||
Util.toast(
|
||||
context,
|
||||
toast(
|
||||
error.errorCode,
|
||||
false
|
||||
false,
|
||||
UApp.applicationContext()
|
||||
)
|
||||
}
|
||||
isJukeboxEnabled = false
|
||||
@ -199,7 +200,7 @@ class MediaPlayerManager(
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
|
||||
val jukebox = activeServerProvider.getActiveServer().jukeboxByDefault
|
||||
val jukebox = it.jukeboxByDefault
|
||||
// Remove all songs when changing servers before turning on Jukebox.
|
||||
// Jukebox wouldn't find the songs on the new server.
|
||||
if (jukebox) controller?.clearMediaItems()
|
||||
@ -246,10 +247,10 @@ class MediaPlayerManager(
|
||||
|
||||
private fun createMediaController(onCreated: () -> Unit) {
|
||||
mediaControllerFuture = MediaController.Builder(
|
||||
context,
|
||||
UApp.applicationContext(),
|
||||
sessionToken
|
||||
)
|
||||
// Specify mainThread explicitely
|
||||
// Specify mainThread explicitly
|
||||
.setApplicationLooper(Looper.getMainLooper())
|
||||
.buildAsync()
|
||||
|
||||
@ -320,17 +321,15 @@ class MediaPlayerManager(
|
||||
externalStorageMonitor.onDestroy()
|
||||
DownloadService.requestStop()
|
||||
created = false
|
||||
Timber.i("MediaPlayerController destroyed")
|
||||
Timber.i("MediaPlayerManager destroyed")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun restore(
|
||||
state: PlaybackState,
|
||||
autoPlay: Boolean,
|
||||
newPlaylist: Boolean
|
||||
autoPlay: Boolean
|
||||
) {
|
||||
val insertionMode = if (newPlaylist) InsertionMode.CLEAR
|
||||
else InsertionMode.APPEND
|
||||
val insertionMode = InsertionMode.APPEND
|
||||
|
||||
addToPlaylist(
|
||||
state.songs,
|
||||
@ -474,6 +473,79 @@ class MediaPlayerManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addToPlaylistAsync(
|
||||
songs: List<Track>,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
insertionMode: InsertionMode
|
||||
) {
|
||||
withContext(Dispatchers.Main) {
|
||||
addToPlaylist(
|
||||
songs = songs,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
insertionMode = insertionMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun playTracksAndToast(
|
||||
fragment: Fragment,
|
||||
insertionMode: InsertionMode,
|
||||
tracks: List<Track> = listOf(),
|
||||
id: String? = null,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
shuffle: Boolean = false,
|
||||
isArtist: Boolean = false
|
||||
) {
|
||||
|
||||
fragment.launchWithToast {
|
||||
|
||||
val list: List<Track> =
|
||||
tracks.ifEmpty {
|
||||
requireNotNull(id)
|
||||
DownloadUtil.getTracksFromServerAsync(isArtist, id, isDirectory, name, isShare)
|
||||
}
|
||||
|
||||
addToPlaylistAsync(
|
||||
songs = list,
|
||||
insertionMode = insertionMode,
|
||||
autoPlay = (insertionMode == InsertionMode.CLEAR),
|
||||
shuffle = shuffle,
|
||||
)
|
||||
|
||||
if (insertionMode == InsertionMode.CLEAR) {
|
||||
fragment.navigateToCurrent()
|
||||
}
|
||||
|
||||
when (insertionMode) {
|
||||
InsertionMode.AFTER_CURRENT ->
|
||||
quantize(R.plurals.n_songs_added_after_current, list)
|
||||
|
||||
InsertionMode.APPEND ->
|
||||
quantize(R.plurals.n_songs_added_to_end, list)
|
||||
|
||||
InsertionMode.CLEAR -> {
|
||||
if (Settings.shouldTransitionOnPlayback)
|
||||
null
|
||||
else
|
||||
quantize(R.plurals.n_songs_added_play_now, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun quantize(resId: Int, tracks: List<Track>): String {
|
||||
return UApp.applicationContext().resources.getQuantityString(
|
||||
resId,
|
||||
tracks.size,
|
||||
tracks.size
|
||||
)
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
var isShufflePlayEnabled: Boolean
|
||||
get() = controller?.shuffleModeEnabled == true
|
||||
@ -649,21 +721,6 @@ class MediaPlayerManager(
|
||||
Timber.i("MediaPlayerController released")
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calls the music service directly and
|
||||
* therefore can't be called from the main thread
|
||||
*/
|
||||
val isJukeboxAvailable: Boolean
|
||||
get() {
|
||||
try {
|
||||
val username = activeServerProvider.getActiveServer().userName
|
||||
return getMusicService().getUser(username).jukeboxRole
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all, "Error getting user information")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun adjustVolume(up: Boolean) {
|
||||
val delta = if (up) VOLUME_DELTA else -VOLUME_DELTA
|
||||
var gain = controller?.volume ?: return
|
||||
@ -676,7 +733,7 @@ class MediaPlayerManager(
|
||||
/*
|
||||
* Sets the rating of the current track
|
||||
*/
|
||||
fun setRating(rating: Rating) {
|
||||
private fun setRating(rating: Rating) {
|
||||
if (controller is MediaController) {
|
||||
(controller as MediaController).setRating(rating)
|
||||
}
|
||||
@ -724,7 +781,7 @@ class MediaPlayerManager(
|
||||
val currentMediaItemIndex: Int
|
||||
get() = controller?.currentMediaItemIndex ?: -1
|
||||
|
||||
fun getCurrentShuffleIndex(): Int {
|
||||
private fun getCurrentShuffleIndex(): Int {
|
||||
val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1
|
||||
return getShuffledIndexOf(currentMediaItemIndex)
|
||||
}
|
||||
@ -768,9 +825,8 @@ class MediaPlayerManager(
|
||||
* in the shuffled timeline.
|
||||
* @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getShuffledIndexOf(searchPosition: Int): Int {
|
||||
return getWindowIndexWhere(false) {
|
||||
_, windowIndex ->
|
||||
private fun getShuffledIndexOf(searchPosition: Int): Int {
|
||||
return getWindowIndexWhere(false) { _, windowIndex ->
|
||||
windowIndex == searchPosition
|
||||
}
|
||||
}
|
||||
@ -784,8 +840,7 @@ class MediaPlayerManager(
|
||||
* @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getUnshuffledIndexOf(shufflePosition: Int): Int {
|
||||
return getWindowIndexWhere(true) {
|
||||
count, _ ->
|
||||
return getWindowIndexWhere(true) { count, _ ->
|
||||
count == shufflePosition
|
||||
}
|
||||
}
|
||||
|
@ -138,6 +138,8 @@ interface MusicService {
|
||||
@Throws(Exception::class)
|
||||
fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String?
|
||||
|
||||
fun isJukeboxAvailable(): Boolean
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus
|
||||
|
||||
|
@ -335,6 +335,10 @@ class OfflineMusicService : MusicService, KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
override fun isJukeboxAvailable(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus {
|
||||
throw OfflineException("Jukebox not available in offline mode")
|
||||
|
@ -1,10 +1,10 @@
|
||||
/*
|
||||
* PlaybackService.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
package org.moire.ultrasonic.playback
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
@ -42,15 +42,11 @@ import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.CachedDataSource
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
@ -68,7 +64,7 @@ class PlaybackService :
|
||||
private var equalizer: EqualizerController? = null
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private lateinit var librarySessionCallback: AutoMediaBrowserCallback
|
||||
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
|
||||
|
||||
private var rxBusSubscription = CompositeDisposable()
|
||||
|
||||
@ -115,6 +111,7 @@ class PlaybackService :
|
||||
isStarted = false
|
||||
stopForegroundRemoveNotification()
|
||||
stopSelf()
|
||||
instance = null
|
||||
}
|
||||
|
||||
private val resolver: ResolvingDataSource.Resolver = ResolvingDataSource.Resolver {
|
||||
@ -142,7 +139,7 @@ class PlaybackService :
|
||||
actualBackend = desiredBackend
|
||||
|
||||
// Create browser interface
|
||||
librarySessionCallback = AutoMediaBrowserCallback()
|
||||
librarySessionCallback = MediaLibrarySessionCallback()
|
||||
|
||||
// This will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
@ -1,7 +1,9 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import androidx.media3.session.MediaSession
|
||||
import java.io.Serializable
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
|
||||
/**
|
||||
* Represents the state of the Media Player implementation
|
||||
@ -17,3 +19,12 @@ data class PlaybackState(
|
||||
private const val serialVersionUID = -293487987L
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun PlaybackState.toMediaItemsWithStartPosition(): MediaSession.MediaItemsWithStartPosition {
|
||||
return MediaSession.MediaItemsWithStartPosition(
|
||||
songs.map { it.toMediaItem() },
|
||||
currentPlayingIndex,
|
||||
currentPlayingPosition.toLong(),
|
||||
)
|
||||
}
|
||||
|
@ -24,6 +24,8 @@ import timber.log.Timber
|
||||
* This class is responsible for the serialization / deserialization
|
||||
* of the playlist and the player state (e.g. current playing number and play position)
|
||||
* to the filesystem.
|
||||
*
|
||||
* TODO: Should use: MediaItemsWithStartPosition
|
||||
*/
|
||||
class PlaybackStateSerializer : KoinComponent {
|
||||
|
||||
@ -56,7 +58,7 @@ class PlaybackStateSerializer : KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
fun serializeNow(
|
||||
private fun serializeNow(
|
||||
tracks: Iterable<Track>,
|
||||
currentPlayingIndex: Int,
|
||||
currentPlayingPosition: Int,
|
||||
@ -85,7 +87,10 @@ class PlaybackStateSerializer : KoinComponent {
|
||||
if (isDeserializing.get()) return
|
||||
ioScope.launch {
|
||||
try {
|
||||
deserializeNow(afterDeserialized)
|
||||
val state = deserializeNow()
|
||||
mainScope.launch {
|
||||
afterDeserialized(state)
|
||||
}
|
||||
isSetup.set(true)
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all, "Had a problem deserializing:")
|
||||
@ -95,11 +100,11 @@ class PlaybackStateSerializer : KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deserializeNow(afterDeserialized: (PlaybackState?) -> Unit?) {
|
||||
fun deserializeNow(): PlaybackState? {
|
||||
|
||||
val state = FileUtil.deserialize<PlaybackState>(
|
||||
context, Constants.FILENAME_PLAYLIST_SER
|
||||
) ?: return
|
||||
) ?: return null
|
||||
|
||||
Timber.i(
|
||||
"Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d, shuffle: %b",
|
||||
@ -108,9 +113,7 @@ class PlaybackStateSerializer : KoinComponent {
|
||||
state.shufflePlay
|
||||
)
|
||||
|
||||
mainScope.launch {
|
||||
afterDeserialized(state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -51,7 +51,7 @@ import timber.log.Timber
|
||||
*/
|
||||
@Suppress("LargeClass")
|
||||
open class RESTMusicService(
|
||||
val subsonicAPIClient: SubsonicAPIClient,
|
||||
private val subsonicAPIClient: SubsonicAPIClient,
|
||||
private val activeServerProvider: ActiveServerProvider
|
||||
) : MusicService {
|
||||
|
||||
@ -504,6 +504,11 @@ open class RESTMusicService(
|
||||
builder.build()
|
||||
}
|
||||
|
||||
override fun isJukeboxAvailable(): Boolean {
|
||||
val username = activeServerProvider.getActiveServer().userName
|
||||
return getUser(username).jukeboxRole
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun updateJukeboxPlaylist(
|
||||
ids: List<String>?
|
||||
|
@ -8,6 +8,7 @@ import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
class RxBus {
|
||||
@ -32,9 +33,9 @@ class RxBus {
|
||||
var activeServerChangingObservable: Observable<Int> =
|
||||
activeServerChangingPublisher
|
||||
|
||||
var activeServerChangedPublisher: PublishSubject<Int> =
|
||||
var activeServerChangedPublisher: PublishSubject<ServerSetting> =
|
||||
PublishSubject.create()
|
||||
var activeServerChangedObservable: Observable<Int> =
|
||||
var activeServerChangedObservable: Observable<ServerSetting> =
|
||||
activeServerChangedPublisher.observeOn(mainThread())
|
||||
|
||||
val themeChangedEventPublisher: PublishSubject<Unit> =
|
||||
|
@ -1,260 +0,0 @@
|
||||
/*
|
||||
* DownloadHandler.kt
|
||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.subsonic
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import java.util.LinkedList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.executeTaskWithToast
|
||||
|
||||
/**
|
||||
* Retrieves a list of songs and adds them to the now playing list
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class DownloadHandler(
|
||||
val mediaPlayerManager: MediaPlayerManager,
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val maxSongs = 500
|
||||
|
||||
fun justDownload(
|
||||
action: DownloadAction,
|
||||
fragment: Fragment,
|
||||
id: String? = null,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
isArtist: Boolean = false,
|
||||
tracks: List<Track>? = null
|
||||
) {
|
||||
var successString: String? = null
|
||||
|
||||
// Launch the Job
|
||||
executeTaskWithToast({
|
||||
val tracksToDownload: List<Track> = tracks
|
||||
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// If we are just downloading tracks we don't need to add them to the controller
|
||||
when (action) {
|
||||
DownloadAction.DOWNLOAD -> DownloadService.download(
|
||||
tracksToDownload,
|
||||
save = false,
|
||||
updateSaveFlag = true
|
||||
)
|
||||
DownloadAction.PIN -> DownloadService.download(
|
||||
tracksToDownload,
|
||||
save = true,
|
||||
updateSaveFlag = true
|
||||
)
|
||||
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
|
||||
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
|
||||
}
|
||||
successString = when (action) {
|
||||
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_to_be_downloaded,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
DownloadAction.UNPIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_unpinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.PIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_pinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.DELETE -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_deleted,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
fun fetchTracksAndAddToController(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
insertionMode: MediaPlayerManager.InsertionMode,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean = false,
|
||||
isArtist: Boolean = false
|
||||
) {
|
||||
var successString: String? = null
|
||||
// Launch the Job
|
||||
executeTaskWithToast({
|
||||
val songs: MutableList<Track> =
|
||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
addTracksToMediaController(
|
||||
songs = songs,
|
||||
insertionMode = insertionMode,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
playlistName = null,
|
||||
fragment = fragment
|
||||
)
|
||||
|
||||
// Play Now doesn't get a Toast :)
|
||||
successString = when (insertionMode) {
|
||||
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_added_after_current,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
MediaPlayerManager.InsertionMode.APPEND ->
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_added_to_end,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
fun addTracksToMediaController(
|
||||
songs: List<Track>,
|
||||
insertionMode: MediaPlayerManager.InsertionMode,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean = false,
|
||||
playlistName: String? = null,
|
||||
fragment: Fragment
|
||||
) {
|
||||
if (songs.isEmpty()) return
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||
}
|
||||
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
|
||||
if (Settings.shouldTransitionOnPlayback &&
|
||||
insertionMode == MediaPlayerManager.InsertionMode.CLEAR
|
||||
) {
|
||||
fragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTracksFromServer(
|
||||
isArtist: Boolean,
|
||||
id: String,
|
||||
isDirectory: Boolean,
|
||||
name: String?,
|
||||
isShare: Boolean
|
||||
): MutableList<Track> {
|
||||
val musicService = getMusicService()
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val root: MusicDirectory
|
||||
if (shouldUseId3Tags() && isArtist) {
|
||||
return getSongsForArtist(id)
|
||||
} else {
|
||||
if (isDirectory) {
|
||||
root = if (shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id, name, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id, name, false)
|
||||
} else if (isShare) {
|
||||
root = MusicDirectory()
|
||||
val shares = musicService.getShares(true)
|
||||
// Filter the received shares by the given id, and get their entries
|
||||
val entries = shares.filter { it.id == id }.flatMap { it.getEntries() }
|
||||
root.addAll(entries)
|
||||
} else {
|
||||
root = musicService.getPlaylist(id, name!!)
|
||||
}
|
||||
getSongsRecursively(root, songs)
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
@Suppress("DestructuringDeclarationWithTooManyEntries")
|
||||
@Throws(Exception::class)
|
||||
private fun getSongsRecursively(
|
||||
parent: MusicDirectory,
|
||||
songs: MutableList<Track>
|
||||
) {
|
||||
if (songs.size > maxSongs) {
|
||||
return
|
||||
}
|
||||
for (song in parent.getTracks()) {
|
||||
if (!song.isVideo) {
|
||||
songs.add(song)
|
||||
}
|
||||
}
|
||||
val musicService = getMusicService()
|
||||
for ((id1, _, _, title) in parent.getAlbums()) {
|
||||
val root: MusicDirectory = if (shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id1, title, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id1, title, false)
|
||||
getSongsRecursively(root, songs)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun getSongsForArtist(
|
||||
id: String
|
||||
): MutableList<Track> {
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val musicService = getMusicService()
|
||||
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
||||
for ((id1) in artist) {
|
||||
val albumDirectory = musicService.getAlbumAsDir(
|
||||
id1,
|
||||
"",
|
||||
false
|
||||
)
|
||||
for (song in albumDirectory.getTracks()) {
|
||||
if (!song.isVideo) {
|
||||
songs.add(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
return songs
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadAction {
|
||||
DOWNLOAD, PIN, UNPIN, DELETE
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package org.moire.ultrasonic.subsonic
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -13,6 +12,7 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.imageloader.ImageLoader
|
||||
import org.moire.ultrasonic.imageloader.ImageLoaderConfig
|
||||
import org.moire.ultrasonic.util.CoroutinePatterns
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
@ -20,8 +20,7 @@ import timber.log.Timber
|
||||
/**
|
||||
* Handles the lifetime of the Image Loader
|
||||
*/
|
||||
class
|
||||
ImageLoaderProvider(val context: Context) :
|
||||
class ImageLoaderProvider :
|
||||
KoinComponent,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private var imageLoader: ImageLoader? = null
|
||||
@ -57,7 +56,7 @@ ImageLoaderProvider(val context: Context) :
|
||||
}
|
||||
|
||||
fun executeOn(cb: (iL: ImageLoader) -> Unit) {
|
||||
launch {
|
||||
launch(CoroutinePatterns.loggingExceptionHandler) {
|
||||
val iL = getImageLoader()
|
||||
withContext(Dispatchers.Main) {
|
||||
cb(iL)
|
||||
|
@ -1,19 +1,19 @@
|
||||
package org.moire.ultrasonic.subsonic
|
||||
|
||||
import android.content.Context
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.util.Util
|
||||
|
||||
/**
|
||||
* Utility class for checking the availability of the network and storage
|
||||
*/
|
||||
class NetworkAndStorageChecker(val context: Context) {
|
||||
class NetworkAndStorageChecker {
|
||||
fun warnIfNetworkOrStorageUnavailable() {
|
||||
if (!Util.isExternalStoragePresent()) {
|
||||
Util.toast(context, R.string.select_album_no_sdcard)
|
||||
Util.toast(R.string.select_album_no_sdcard, true, UApp.applicationContext())
|
||||
} else if (!isOffline() && !Util.hasUsableNetwork()) {
|
||||
Util.toast(context, R.string.select_album_no_network)
|
||||
Util.toast(R.string.select_album_no_network, true, UApp.applicationContext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@
|
||||
package org.moire.ultrasonic.subsonic
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@ -17,27 +16,27 @@ import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.Share
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||
import org.moire.ultrasonic.util.FragmentBackgroundTask
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.ShareDetails
|
||||
import org.moire.ultrasonic.util.TimeSpanPicker
|
||||
import org.moire.ultrasonic.util.Util.getString
|
||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
||||
|
||||
/**
|
||||
* This class handles sharing items in the media library
|
||||
*/
|
||||
class ShareHandler(val context: Context) {
|
||||
class ShareHandler {
|
||||
private var shareDescription: EditText? = null
|
||||
private var timeSpanPicker: TimeSpanPicker? = null
|
||||
private var shareOnServerCheckBox: CheckBox? = null
|
||||
@ -51,27 +50,32 @@ class ShareHandler(val context: Context) {
|
||||
fun share(
|
||||
fragment: Fragment,
|
||||
shareDetails: ShareDetails,
|
||||
swipe: SwipeRefreshLayout?,
|
||||
cancellationToken: CancellationToken,
|
||||
additionalId: String?
|
||||
) {
|
||||
val task: BackgroundTask<Share?> = object : FragmentBackgroundTask<Share?>(
|
||||
fragment.requireActivity(),
|
||||
true,
|
||||
swipe,
|
||||
cancellationToken
|
||||
) {
|
||||
@Throws(Throwable::class)
|
||||
override fun doInBackground(): Share? {
|
||||
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
|
||||
scope.launch {
|
||||
val share = createShareOnServer(shareDetails, additionalId)
|
||||
startActivityForShare(share, shareDetails, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createShareOnServer(
|
||||
shareDetails: ShareDetails,
|
||||
additionalId: String?
|
||||
): Share? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
|
||||
val ids: MutableList<String> = ArrayList()
|
||||
|
||||
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null
|
||||
if (shareDetails.Entries.isEmpty()) {
|
||||
if (!shareDetails.shareOnServer && shareDetails.entries.size == 1)
|
||||
return@withContext null
|
||||
if (shareDetails.entries.isEmpty()) {
|
||||
additionalId.ifNotNull {
|
||||
ids.add(it)
|
||||
}
|
||||
} else {
|
||||
for ((id) in shareDetails.Entries) {
|
||||
for ((id) in shareDetails.entries) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
@ -79,85 +83,93 @@ class ShareHandler(val context: Context) {
|
||||
val musicService = getMusicService()
|
||||
var timeInMillis: Long = 0
|
||||
|
||||
if (shareDetails.Expiration != 0L) {
|
||||
timeInMillis = shareDetails.Expiration
|
||||
if (shareDetails.expiration != 0L) {
|
||||
timeInMillis = shareDetails.expiration
|
||||
}
|
||||
|
||||
val shares =
|
||||
musicService.createShare(ids, shareDetails.Description, timeInMillis)
|
||||
|
||||
return shares[0]
|
||||
}
|
||||
|
||||
override fun done(result: Share?) {
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "text/plain"
|
||||
|
||||
if (result != null) {
|
||||
// Created a share, send the URL
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
String.format(
|
||||
Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Sending only text details
|
||||
val textBuilder = StringBuilder()
|
||||
textBuilder.appendLine(Settings.shareGreeting)
|
||||
|
||||
if (!shareDetails.Entries[0].title.isNullOrEmpty())
|
||||
textBuilder.append(context.resources.getString(R.string.common_title))
|
||||
.append(": ").appendLine(shareDetails.Entries[0].title)
|
||||
if (!shareDetails.Entries[0].artist.isNullOrEmpty())
|
||||
textBuilder.append(context.resources.getString(R.string.common_artist))
|
||||
.append(": ").appendLine(shareDetails.Entries[0].artist)
|
||||
if (!shareDetails.Entries[0].album.isNullOrEmpty())
|
||||
textBuilder.append(context.resources.getString(R.string.common_album))
|
||||
.append(": ").append(shareDetails.Entries[0].album)
|
||||
|
||||
intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString())
|
||||
}
|
||||
|
||||
fragment.activity?.startActivity(
|
||||
Intent.createChooser(
|
||||
intent,
|
||||
context.resources.getString(R.string.share_via)
|
||||
)
|
||||
val shares = musicService.createShare(
|
||||
ids = ids,
|
||||
description = shareDetails.description,
|
||||
expires = timeInMillis
|
||||
)
|
||||
|
||||
// Return the share
|
||||
if (shares.isNotEmpty())
|
||||
shares[0]
|
||||
else
|
||||
null
|
||||
} catch (ignored: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
task.execute()
|
||||
}
|
||||
|
||||
private suspend fun startActivityForShare(
|
||||
result: Share?,
|
||||
shareDetails: ShareDetails,
|
||||
fragment: Fragment
|
||||
) {
|
||||
return withContext(Dispatchers.Main) {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "text/plain"
|
||||
|
||||
if (result != null) {
|
||||
// Created a share, send the URL
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
String.format(
|
||||
Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Sending only text details
|
||||
val textBuilder = StringBuilder()
|
||||
textBuilder.appendLine(Settings.shareGreeting)
|
||||
|
||||
if (!shareDetails.entries[0].title.isNullOrEmpty())
|
||||
textBuilder.append(getString(R.string.common_title))
|
||||
.append(": ").appendLine(shareDetails.entries[0].title)
|
||||
if (!shareDetails.entries[0].artist.isNullOrEmpty())
|
||||
textBuilder.append(getString(R.string.common_artist))
|
||||
.append(": ").appendLine(shareDetails.entries[0].artist)
|
||||
if (!shareDetails.entries[0].album.isNullOrEmpty())
|
||||
textBuilder.append(getString(R.string.common_album))
|
||||
.append(": ").append(shareDetails.entries[0].album)
|
||||
|
||||
intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString())
|
||||
}
|
||||
|
||||
fragment.activity?.startActivity(
|
||||
Intent.createChooser(
|
||||
intent,
|
||||
getString(R.string.share_via)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createShare(
|
||||
fragment: Fragment,
|
||||
tracks: List<Track?>?,
|
||||
swipe: SwipeRefreshLayout?,
|
||||
cancellationToken: CancellationToken,
|
||||
tracks: List<Track>,
|
||||
additionalId: String? = null
|
||||
) {
|
||||
if (tracks.isEmpty()) return
|
||||
val askForDetails = Settings.shouldAskForShareDetails
|
||||
val shareDetails = ShareDetails()
|
||||
shareDetails.Entries = tracks
|
||||
val shareDetails = ShareDetails(tracks)
|
||||
if (askForDetails) {
|
||||
showDialog(fragment, shareDetails, swipe, cancellationToken, additionalId)
|
||||
showDialog(fragment, shareDetails, additionalId)
|
||||
} else {
|
||||
shareDetails.Description = Settings.defaultShareDescription
|
||||
shareDetails.Expiration = System.currentTimeMillis() +
|
||||
shareDetails.description = Settings.defaultShareDescription
|
||||
shareDetails.expiration = System.currentTimeMillis() +
|
||||
Settings.defaultShareExpirationInMillis
|
||||
share(fragment, shareDetails, swipe, cancellationToken, additionalId)
|
||||
share(fragment, shareDetails, additionalId)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@SuppressLint("InflateParams")
|
||||
private fun showDialog(
|
||||
fragment: Fragment,
|
||||
shareDetails: ShareDetails,
|
||||
swipe: SwipeRefreshLayout?,
|
||||
cancellationToken: CancellationToken,
|
||||
additionalId: String?
|
||||
) {
|
||||
val layout = LayoutInflater.from(fragment.context).inflate(R.layout.share_details, null)
|
||||
@ -171,67 +183,35 @@ class ShareHandler(val context: Context) {
|
||||
noExpirationCheckBox = timeSpanPicker!!.findViewById<View>(
|
||||
R.id.timeSpanDisableCheckBox
|
||||
) as CheckBox
|
||||
textViewComment = layout.findViewById<View>(R.id.textViewComment) as TextView
|
||||
textViewExpiration = layout.findViewById<View>(R.id.textViewExpiration) as TextView
|
||||
textViewComment = layout.findViewById<View>(R.id.commentHeading) as TextView
|
||||
textViewExpiration = layout.findViewById<View>(R.id.expirationHeading) as TextView
|
||||
}
|
||||
|
||||
if (shareDetails.Entries.size == 1) {
|
||||
// For single songs the sharing may be done by text only
|
||||
// Handle the visibility based on shareDetails.Entries size
|
||||
if (shareDetails.entries.size == 1) {
|
||||
shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ ->
|
||||
updateVisibility()
|
||||
}
|
||||
|
||||
shareOnServerCheckBox?.isChecked = Settings.shareOnServer
|
||||
} else {
|
||||
shareOnServerCheckBox?.isVisible = false
|
||||
}
|
||||
|
||||
updateVisibility()
|
||||
|
||||
val builder = ConfirmationDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.share_set_share_options)
|
||||
// Set up the dialog builder
|
||||
val builder = makeDialogBuilder(fragment, shareDetails, additionalId, layout)
|
||||
|
||||
builder.setPositiveButton(R.string.menu_share) { _, _ ->
|
||||
if (!noExpirationCheckBox!!.isChecked) {
|
||||
val timeSpan: Long = timeSpanPicker!!.getTimeSpan()
|
||||
shareDetails.Expiration = System.currentTimeMillis() + timeSpan
|
||||
}
|
||||
// Initialize UI components with default values
|
||||
setupDefaultValues()
|
||||
|
||||
shareDetails.Description = shareDescription!!.text.toString()
|
||||
shareDetails.ShareOnServer = shareOnServerCheckBox!!.isChecked
|
||||
|
||||
if (hideDialogCheckBox!!.isChecked) {
|
||||
Settings.shouldAskForShareDetails = false
|
||||
}
|
||||
|
||||
if (saveAsDefaultsCheckBox!!.isChecked) {
|
||||
val timeSpanType: String = timeSpanPicker!!.timeSpanType!!
|
||||
val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount
|
||||
Settings.defaultShareExpiration =
|
||||
if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0)
|
||||
String.format("%d:%s", timeSpanAmount, timeSpanType) else ""
|
||||
|
||||
Settings.defaultShareDescription = shareDetails.Description
|
||||
Settings.shareOnServer = shareDetails.ShareOnServer
|
||||
}
|
||||
|
||||
share(fragment, shareDetails, swipe, cancellationToken, additionalId)
|
||||
}
|
||||
|
||||
builder.setNegativeButton(R.string.common_cancel) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
|
||||
builder.setView(layout)
|
||||
builder.setCancelable(true)
|
||||
|
||||
timeSpanPicker!!.setTimeSpanDisableText(context.resources.getString(R.string.no_expiration))
|
||||
noExpirationCheckBox!!.setOnCheckedChangeListener { _, b ->
|
||||
timeSpanPicker!!.isEnabled = !b
|
||||
}
|
||||
builder.create()
|
||||
builder.show()
|
||||
}
|
||||
|
||||
private fun setupDefaultValues() {
|
||||
val defaultDescription = Settings.defaultShareDescription
|
||||
val timeSpan = Settings.defaultShareExpiration
|
||||
|
||||
val split = pattern.split(timeSpan)
|
||||
if (split.size == 2) {
|
||||
val timeSpanAmount = split[0].toInt()
|
||||
@ -249,10 +229,58 @@ class ShareHandler(val context: Context) {
|
||||
noExpirationCheckBox!!.isChecked = true
|
||||
timeSpanPicker!!.isEnabled = false
|
||||
}
|
||||
|
||||
shareDescription!!.setText(defaultDescription)
|
||||
builder.create()
|
||||
builder.show()
|
||||
}
|
||||
|
||||
private fun makeDialogBuilder(
|
||||
fragment: Fragment,
|
||||
shareDetails: ShareDetails,
|
||||
additionalId: String?,
|
||||
layout: View?
|
||||
): ConfirmationDialog.Builder {
|
||||
val builder = ConfirmationDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.share_set_share_options)
|
||||
|
||||
builder.setPositiveButton(R.string.menu_share) { _, _ ->
|
||||
if (!noExpirationCheckBox!!.isChecked) {
|
||||
val timeSpan: Long = timeSpanPicker!!.getTimeSpan()
|
||||
shareDetails.expiration = System.currentTimeMillis() + timeSpan
|
||||
}
|
||||
|
||||
shareDetails.description = shareDescription!!.text.toString()
|
||||
shareDetails.shareOnServer = shareOnServerCheckBox!!.isChecked
|
||||
|
||||
if (hideDialogCheckBox!!.isChecked) {
|
||||
Settings.shouldAskForShareDetails = false
|
||||
}
|
||||
|
||||
if (saveAsDefaultsCheckBox!!.isChecked) {
|
||||
val timeSpanType: String = timeSpanPicker!!.timeSpanType!!
|
||||
val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount
|
||||
Settings.defaultShareExpiration =
|
||||
if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0)
|
||||
String.format("%d:%s", timeSpanAmount, timeSpanType) else ""
|
||||
|
||||
Settings.defaultShareDescription = shareDetails.description!!
|
||||
Settings.shareOnServer = shareDetails.shareOnServer
|
||||
}
|
||||
|
||||
share(fragment, shareDetails, additionalId)
|
||||
}
|
||||
|
||||
builder.setNegativeButton(R.string.common_cancel) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
|
||||
builder.setView(layout)
|
||||
builder.setCancelable(true)
|
||||
|
||||
// Set up the timeSpanPicker
|
||||
timeSpanPicker!!.setTimeSpanDisableText(getString(R.string.no_expiration))
|
||||
noExpirationCheckBox!!.setOnCheckedChangeListener { _, b ->
|
||||
timeSpanPicker!!.isEnabled = !b
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun updateVisibility() {
|
||||
|
@ -16,7 +16,7 @@ class VideoPlayer {
|
||||
companion object {
|
||||
fun playVideo(context: Context, track: Track?) {
|
||||
if (!Util.hasUsableNetwork() || track == null) {
|
||||
Util.toast(context, R.string.select_album_no_network)
|
||||
Util.toast(R.string.select_album_no_network, true, context)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@ -32,7 +32,7 @@ class VideoPlayer {
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (all: Exception) {
|
||||
Util.toast(context, all.toString(), false)
|
||||
Util.toast(all.toString(), false, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ import android.system.Os
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.guava.future
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@ -19,7 +18,7 @@ import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Playlist
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||
@ -38,7 +37,6 @@ import timber.log.Timber
|
||||
*/
|
||||
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinComponent {
|
||||
|
||||
private var mainScope = CoroutineScope(Dispatchers.Main)
|
||||
private val activeServerProvider by inject<ActiveServerProvider>()
|
||||
|
||||
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
|
||||
@ -235,16 +233,14 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
|
||||
|
||||
private fun findFilesToNotDelete(): Set<String> {
|
||||
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
||||
val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
val playlist = mainScope.future { mediaPlayerManager.playlist }.get()
|
||||
for (item in playlist) {
|
||||
val track = item.toTrack()
|
||||
// We just take the last published playlist from RX
|
||||
val playlist = RxBus.playlistObservable.blockingLast()
|
||||
for (track in playlist) {
|
||||
filesToNotDelete.add(track.getPartialFile())
|
||||
filesToNotDelete.add(track.getCompleteFile())
|
||||
filesToNotDelete.add(track.getPinnedFile())
|
||||
}
|
||||
|
||||
filesToNotDelete.add(musicDirectory.path)
|
||||
return filesToNotDelete
|
||||
}
|
||||
|
@ -8,7 +8,9 @@ package org.moire.ultrasonic.util
|
||||
|
||||
/**
|
||||
* This class contains a very simple implementation of a CancellationToken
|
||||
*/
|
||||
* TODO: Remove this class and refactor all user to coroutines
|
||||
**/
|
||||
|
||||
class CancellationToken {
|
||||
var isCancellationRequested: Boolean = false
|
||||
|
||||
|
@ -37,4 +37,5 @@ object Constants {
|
||||
const val FILENAME_PLAYLIST_SER = "downloadstate.ser"
|
||||
const val ALBUM_ART_FILE = "folder.jpeg"
|
||||
const val RESULT_CLOSE_ALL = 1337
|
||||
const val MAX_SONGS_RECURSIVE = 500
|
||||
}
|
||||
|
@ -0,0 +1,140 @@
|
||||
/*
|
||||
* ContextMenuUtil.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
|
||||
object ContextMenuUtil : KoinComponent {
|
||||
|
||||
/*
|
||||
* Callback for menu items of collections (albums, artists etc)
|
||||
*/
|
||||
fun handleContextMenu(
|
||||
menuItem: MenuItem,
|
||||
item: Identifiable,
|
||||
isArtist: Boolean,
|
||||
mediaPlayerManager: MediaPlayerManager,
|
||||
fragment: Fragment
|
||||
): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_play_now ->
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_next ->
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_last ->
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_pin ->
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment = fragment,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_unpin ->
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = fragment,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_download ->
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment = fragment,
|
||||
id = item.id,
|
||||
isArtist = isArtist
|
||||
)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun handleContextMenuTracks(
|
||||
menuItem: MenuItem,
|
||||
tracks: List<Track>,
|
||||
mediaPlayerManager: MediaPlayerManager,
|
||||
fragment: Fragment
|
||||
): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.song_menu_play_now -> {
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
mediaPlayerManager.playTracksAndToast(
|
||||
fragment = fragment,
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_pin -> {
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment = fragment,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_unpin -> {
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = fragment,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_download -> {
|
||||
DownloadUtil.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment = fragment,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
R.id.song_menu_share -> {
|
||||
val shareHandler: ShareHandler by inject()
|
||||
shareHandler.createShare(
|
||||
fragment = fragment,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
@ -10,14 +10,15 @@ package org.moire.ultrasonic.util
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.util.CommunicationError.getErrorMessage
|
||||
import org.moire.ultrasonic.util.Util.toast
|
||||
import timber.log.Timber
|
||||
|
||||
object CoroutinePatterns {
|
||||
@ -30,53 +31,69 @@ object CoroutinePatterns {
|
||||
}
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithToast(
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String?
|
||||
): Job {
|
||||
fun Fragment.toastingExceptionHandler(
|
||||
prefix: String = ""
|
||||
): CoroutineExceptionHandler {
|
||||
return CoroutineExceptionHandler { _, exception ->
|
||||
// Stop the spinner if applicable
|
||||
if (this is RefreshableFragment) {
|
||||
this.swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
toast("$prefix ${getErrorMessage(exception)}", shortDuration = false)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Launch a coroutine with a toast
|
||||
* This extension can be only started from a fragment
|
||||
* because it needs the fragments scope to create the toast
|
||||
*/
|
||||
fun Fragment.launchWithToast(
|
||||
block: suspend CoroutineScope.() -> String?
|
||||
) {
|
||||
// Get the scope
|
||||
val scope = activity?.lifecycleScope ?: lifecycleScope
|
||||
|
||||
// Launch the Job
|
||||
val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task)
|
||||
val deferred = scope.async(block = block)
|
||||
|
||||
// Setup a handler when the job is done
|
||||
job.invokeOnCompletion {
|
||||
deferred.invokeOnCompletion {
|
||||
val toastString = if (it != null && it !is CancellationException) {
|
||||
CommunicationError.getErrorMessage(it)
|
||||
getErrorMessage(it)
|
||||
} else {
|
||||
successString()
|
||||
null
|
||||
}
|
||||
|
||||
// Return early if nothing to post
|
||||
if (toastString == null) return@invokeOnCompletion
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
Util.toast(UApp.applicationContext(), toastString)
|
||||
}
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithModalDialog(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String
|
||||
) {
|
||||
// Create the job
|
||||
val job = executeTaskWithToast(task, successString)
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.background_task_wait)
|
||||
builder.setMessage(R.string.background_task_loading)
|
||||
builder.setOnCancelListener { job.cancel() }
|
||||
builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// Add additional handler to close the dialog
|
||||
job.invokeOnCompletion {
|
||||
launch(Dispatchers.Main) {
|
||||
dialog.dismiss()
|
||||
scope.launch(Dispatchers.Main) {
|
||||
val successString = toastString ?: deferred.await()
|
||||
if (successString != null) {
|
||||
this@launchWithToast.toast(successString)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unused, kept commented for eventual later use
|
||||
// fun CoroutineScope.executeTaskWithModalDialog(
|
||||
// fragment: Fragment,
|
||||
// task: suspend CoroutineScope.() -> String?
|
||||
// ) {
|
||||
// // Create the job
|
||||
// val job = launchWithToast(task)
|
||||
//
|
||||
// // Create the dialog
|
||||
// val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
// builder.setTitle(R.string.background_task_wait)
|
||||
// builder.setMessage(R.string.background_task_loading)
|
||||
// builder.setOnCancelListener { job.cancel() }
|
||||
// builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
|
||||
// val dialog = builder.create()
|
||||
// dialog.show()
|
||||
//
|
||||
// // Add additional handler to close the dialog
|
||||
// job.invokeOnCompletion {
|
||||
// launch(Dispatchers.Main) {
|
||||
// dialog.dismiss()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
@ -0,0 +1,195 @@
|
||||
/*
|
||||
* DownloadUtil.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import java.util.LinkedList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
|
||||
/**
|
||||
* Retrieves a list of songs and adds them to the now playing list
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
object DownloadUtil {
|
||||
|
||||
fun justDownload(
|
||||
action: DownloadAction,
|
||||
fragment: Fragment,
|
||||
id: String? = null,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
isArtist: Boolean = false,
|
||||
tracks: List<Track>? = null
|
||||
) {
|
||||
|
||||
// Launch the Job
|
||||
fragment.launchWithToast {
|
||||
val tracksToDownload: List<Track> = tracks
|
||||
?: getTracksFromServerAsync(isArtist, id!!, isDirectory, name, isShare)
|
||||
|
||||
// If we are just downloading tracks we don't need to add them to the controller
|
||||
when (action) {
|
||||
DownloadAction.DOWNLOAD -> DownloadService.downloadAsync(
|
||||
tracksToDownload,
|
||||
save = false,
|
||||
updateSaveFlag = true
|
||||
)
|
||||
DownloadAction.PIN -> DownloadService.downloadAsync(
|
||||
tracksToDownload,
|
||||
save = true,
|
||||
updateSaveFlag = true
|
||||
)
|
||||
DownloadAction.UNPIN -> DownloadService.unpinAsync(tracksToDownload)
|
||||
DownloadAction.DELETE -> DownloadService.deleteAsync(tracksToDownload)
|
||||
}
|
||||
|
||||
// Return the string which should be displayed
|
||||
getToastString(action, fragment, tracksToDownload)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTracksFromServerAsync(
|
||||
isArtist: Boolean,
|
||||
id: String,
|
||||
isDirectory: Boolean,
|
||||
name: String?,
|
||||
isShare: Boolean
|
||||
): MutableList<Track> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTracksFromServer(
|
||||
isArtist: Boolean,
|
||||
id: String,
|
||||
isDirectory: Boolean,
|
||||
name: String?,
|
||||
isShare: Boolean
|
||||
): MutableList<Track> {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val root: MusicDirectory
|
||||
if (ActiveServerProvider.shouldUseId3Tags() && isArtist) {
|
||||
return getSongsForArtist(id)
|
||||
} else {
|
||||
if (isDirectory) {
|
||||
root = if (ActiveServerProvider.shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id, name, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id, name, false)
|
||||
} else if (isShare) {
|
||||
root = MusicDirectory()
|
||||
val shares = musicService.getShares(true)
|
||||
// Filter the received shares by the given id, and get their entries
|
||||
val entries = shares.filter { it.id == id }.flatMap { it.getEntries() }
|
||||
root.addAll(entries)
|
||||
} else {
|
||||
root = musicService.getPlaylist(id, name!!)
|
||||
}
|
||||
getSongsRecursively(root, songs)
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
@Suppress("DestructuringDeclarationWithTooManyEntries")
|
||||
@Throws(Exception::class)
|
||||
private fun getSongsRecursively(
|
||||
parent: MusicDirectory,
|
||||
songs: MutableList<Track>
|
||||
) {
|
||||
if (songs.size > Constants.MAX_SONGS_RECURSIVE) {
|
||||
return
|
||||
}
|
||||
for (song in parent.getTracks()) {
|
||||
if (!song.isVideo) {
|
||||
songs.add(song)
|
||||
}
|
||||
}
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
for ((id1, _, _, title) in parent.getAlbums()) {
|
||||
val root: MusicDirectory = if (ActiveServerProvider.shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id1, title, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id1, title, false)
|
||||
getSongsRecursively(root, songs)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun getSongsForArtist(
|
||||
id: String
|
||||
): MutableList<Track> {
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
||||
for ((id1) in artist) {
|
||||
val albumDirectory = musicService.getAlbumAsDir(
|
||||
id1,
|
||||
"",
|
||||
false
|
||||
)
|
||||
for (song in albumDirectory.getTracks()) {
|
||||
if (!song.isVideo) {
|
||||
songs.add(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
private fun getToastString(
|
||||
action: DownloadAction,
|
||||
fragment: Fragment,
|
||||
tracksToDownload: List<Track>
|
||||
): String {
|
||||
return when (action) {
|
||||
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_to_be_downloaded,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
|
||||
DownloadAction.UNPIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_unpinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
|
||||
DownloadAction.PIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_pinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
|
||||
DownloadAction.DELETE -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.n_songs_deleted,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadAction {
|
||||
DOWNLOAD, PIN, UNPIN, DELETE
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
/*
|
||||
* RefreshableFragment.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
||||
interface RefreshableFragment {
|
||||
var swipeRefresh: SwipeRefreshLayout?
|
||||
}
|
@ -136,10 +136,6 @@ object Settings {
|
||||
val seekIntervalMillis: Long
|
||||
get() = (seekInterval / 1000).toLong()
|
||||
|
||||
@JvmStatic
|
||||
var mediaButtonsEnabled
|
||||
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)
|
||||
|
||||
var resumePlayOnHeadphonePlug
|
||||
by BooleanSetting(R.string.setting_key_resume_play_on_headphones_plug, true)
|
||||
|
||||
|
@ -0,0 +1,12 @@
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
/**
|
||||
* Created by Josh on 12/17/13.
|
||||
*/
|
||||
data class ShareDetails(val entries: List<Track>) {
|
||||
var description: String? = null
|
||||
var shareOnServer = false
|
||||
var expiration: Long = 0
|
||||
}
|
@ -41,7 +41,7 @@ object Storage {
|
||||
if (rootNotFoundError) {
|
||||
Settings.customCacheLocation = false
|
||||
Settings.cacheLocationUri = ""
|
||||
Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error)
|
||||
Util.toast(R.string.settings_cache_location_error, true, UApp.applicationContext())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,7 +49,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty
|
||||
dialog = inflater.inflate(R.layout.time_span_dialog, this, true)
|
||||
timeSpanEditText = dialog.findViewById<View>(R.id.timeSpanEditText) as EditText
|
||||
timeSpanEditText.setText("0")
|
||||
timeSpanSpinner = dialog.findViewById<View>(R.id.timeSpanSpinner) as Spinner
|
||||
timeSpanSpinner = dialog.findViewById<View>(R.id.timeSpanUnitSelector) as Spinner
|
||||
timeSpanDisableCheckbox =
|
||||
dialog.findViewById<View>(R.id.timeSpanDisableCheckBox) as CheckBox
|
||||
timeSpanDisableCheckbox.setOnCheckedChangeListener { _, b ->
|
||||
@ -128,7 +128,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty
|
||||
companion object {
|
||||
fun getTimeSpanFromDialog(context: Context, dialog: View): Long {
|
||||
val timeSpanEditText = dialog.findViewById<View>(R.id.timeSpanEditText) as EditText
|
||||
val timeSpanSpinner = dialog.findViewById<View>(R.id.timeSpanSpinner) as Spinner
|
||||
val timeSpanSpinner = dialog.findViewById<View>(R.id.timeSpanUnitSelector) as Spinner
|
||||
val timeSpanType = timeSpanSpinner.selectedItem as String
|
||||
Timber.i("SELECTED ITEM: %d", timeSpanSpinner.selectedItemId)
|
||||
val text = timeSpanEditText.text
|
||||
|
@ -0,0 +1,27 @@
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.preference.DialogPreference
|
||||
import org.moire.ultrasonic.R
|
||||
|
||||
/**
|
||||
* Created by Joshua Bahnsen on 12/22/13.
|
||||
*/
|
||||
class TimeSpanPreference(mContext: Context, attrs: AttributeSet?) : DialogPreference(
|
||||
mContext, attrs
|
||||
) {
|
||||
init {
|
||||
setPositiveButtonText(android.R.string.ok)
|
||||
setNegativeButtonText(android.R.string.cancel)
|
||||
dialogIcon = null
|
||||
}
|
||||
|
||||
val text: String
|
||||
get() {
|
||||
val persisted = getPersistedString("")
|
||||
return if ("" != persisted) {
|
||||
persisted.replace(':', ' ')
|
||||
} else context.resources.getString(R.string.time_span_disabled)
|
||||
}
|
||||
}
|
@ -8,7 +8,6 @@
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@ -31,18 +30,20 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.text.TextUtils
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Gravity
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.AnyRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import java.io.Closeable
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.security.MessageDigest
|
||||
@ -85,7 +86,6 @@ object Util {
|
||||
// Used by hexEncode()
|
||||
private val HEX_DIGITS =
|
||||
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
|
||||
private var toast: Toast? = null
|
||||
|
||||
// Retrieves an instance of the application Context
|
||||
fun appContext(): Context {
|
||||
@ -107,52 +107,68 @@ object Util {
|
||||
context.getString(R.string.setting_key_theme_dark) -> {
|
||||
R.style.UltrasonicTheme_Dark
|
||||
}
|
||||
|
||||
context.getString(R.string.setting_key_theme_black) -> {
|
||||
R.style.UltrasonicTheme_Black
|
||||
}
|
||||
|
||||
context.getString(R.string.setting_key_theme_light) -> {
|
||||
R.style.UltrasonicTheme_Light
|
||||
}
|
||||
|
||||
else -> {
|
||||
R.style.UltrasonicTheme_DayNight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getString(@StringRes resId: Int): String {
|
||||
return applicationContext().resources.getString(resId)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) {
|
||||
toast(context, context!!.getString(messageId), shortDuration)
|
||||
fun toast(messageId: Int, shortDuration: Boolean = true, context: Context?) {
|
||||
toast(applicationContext().getString(messageId), shortDuration, context)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun toast(context: Context?, message: CharSequence?) {
|
||||
toast(context, message, true)
|
||||
fun toast(message: CharSequence, context: Context?) {
|
||||
toast(message, true, context)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@SuppressLint("ShowToast") // Invalid warning
|
||||
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
|
||||
// If called after doing some background processing, our context might have expired!
|
||||
// Toast needs a real context or it will throw a IllegalAccessException
|
||||
// We wrap it in a try-catch block, because if called after doing
|
||||
// some background processing, our context might have expired!
|
||||
fun toast(message: CharSequence, shortDuration: Boolean, context: Context?) {
|
||||
try {
|
||||
if (toast == null) {
|
||||
toast = Toast.makeText(
|
||||
context,
|
||||
message,
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
)
|
||||
toast!!.setGravity(Gravity.CENTER, 0, 0)
|
||||
} else {
|
||||
toast!!.setText(message)
|
||||
toast!!.duration =
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
}
|
||||
toast!!.show()
|
||||
Toast.makeText(
|
||||
context,
|
||||
message,
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
).show()
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all)
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.toast(message: CharSequence, shortDuration: Boolean = true) {
|
||||
toast(
|
||||
message,
|
||||
shortDuration = shortDuration,
|
||||
context = this.context
|
||||
)
|
||||
}
|
||||
|
||||
fun Fragment.toast(messageId: Int = 0, shortDuration: Boolean = true) {
|
||||
toast(
|
||||
messageId = messageId,
|
||||
shortDuration = shortDuration,
|
||||
context = this.context
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a byte-count to a formatted string suitable for display to the user.
|
||||
* For instance:
|
||||
@ -251,15 +267,16 @@ object Util {
|
||||
* @param s The string to encode.
|
||||
* @return The encoded string.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught")
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
fun utf8HexEncode(s: String?): String? {
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
val utf8: ByteArray = try {
|
||||
s.toByteArray(charset(Constants.UTF_8))
|
||||
} catch (x: UnsupportedEncodingException) {
|
||||
throw RuntimeException(x)
|
||||
} catch (all: UnsupportedEncodingException) {
|
||||
// TODO: Why is it needed to change the exception type here?
|
||||
throw RuntimeException(all)
|
||||
}
|
||||
return hexEncode(utf8)
|
||||
}
|
||||
@ -273,7 +290,7 @@ object Util {
|
||||
* @return A string containing hexadecimal characters.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun hexEncode(data: ByteArray): String {
|
||||
private fun hexEncode(data: ByteArray): String {
|
||||
val length = data.size
|
||||
val out = CharArray(length shl 1)
|
||||
var j = 0
|
||||
@ -293,15 +310,16 @@ object Util {
|
||||
* @return MD5 digest as a hex string.
|
||||
*/
|
||||
@JvmStatic
|
||||
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught")
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
fun md5Hex(s: String?): String? {
|
||||
return if (s == null) {
|
||||
null
|
||||
} else try {
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
hexEncode(md5.digest(s.toByteArray(charset(Constants.UTF_8))))
|
||||
} catch (x: Exception) {
|
||||
throw RuntimeException(x.message, x)
|
||||
} catch (all: Exception) {
|
||||
// TODO: Why is it needed to change the exception type here?
|
||||
throw RuntimeException(all.message, all)
|
||||
}
|
||||
}
|
||||
|
||||
@ -479,7 +497,7 @@ object Util {
|
||||
|
||||
@JvmStatic
|
||||
fun isNullOrWhiteSpace(string: String?): Boolean {
|
||||
return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty()
|
||||
return string.isNullOrEmpty() || string.trim { it <= ' ' }.isEmpty()
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@ -504,18 +522,22 @@ object Util {
|
||||
seconds
|
||||
)
|
||||
}
|
||||
|
||||
hours > 0 -> {
|
||||
String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
minutes >= DEGRADE_PRECISION_AFTER -> {
|
||||
String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
minutes > 0 -> String.format(
|
||||
Locale.getDefault(),
|
||||
"%d:%02d",
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
|
||||
else -> String.format(Locale.getDefault(), "0:%02d", seconds)
|
||||
}
|
||||
}
|
||||
@ -557,7 +579,7 @@ object Util {
|
||||
val requestPermissionLauncher =
|
||||
fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
if (!it) {
|
||||
toast(applicationContext(), R.string.notification_permission_required)
|
||||
toast(R.string.notification_permission_required, context = fragment)
|
||||
}
|
||||
}
|
||||
|
||||
@ -569,6 +591,7 @@ object Util {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun postNotificationIfPermitted(
|
||||
notificationManagerCompat: NotificationManagerCompat,
|
||||
id: Int,
|
||||
@ -583,8 +606,8 @@ object Util {
|
||||
notificationManagerCompat.notify(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("DEPRECATION")
|
||||
fun getVersionName(context: Context): String? {
|
||||
var versionName: String? = null
|
||||
val pm = context.packageManager
|
||||
@ -838,4 +861,11 @@ object Util {
|
||||
Timber.d("${it.key}: ${it.value}")
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.navigateToCurrent() {
|
||||
if (Settings.shouldTransitionOnPlayback) {
|
||||
findNavController().popBackStack(R.id.playerFragment, true)
|
||||
findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +45,7 @@
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toEndOf="@+id/chip_view_toggle"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_default="wrap">
|
||||
>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
|
||||
a:id="@+id/sort_order_menu_options"
|
||||
|
@ -58,6 +58,7 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/header_select_server"
|
||||
app:layout_constraintTop_toTopOf="@id/header_select_server"
|
||||
app:strokeColor="@null"
|
||||
app:tint="@color/selected_menu_dark" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -1,70 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ScrollView xmlns:a="http://schemas.android.com/apk/res/android"
|
||||
a:id="@+id/share_details"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content">
|
||||
a:id="@+id/share_details"
|
||||
a:layout_width="match_parent"
|
||||
a:layout_height="wrap_content"
|
||||
a:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
a:layout_width="match_parent"
|
||||
a:layout_height="wrap_content"
|
||||
a:orientation="vertical"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content">
|
||||
a:padding="16dp">
|
||||
|
||||
<CheckBox
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
a:id="@+id/share_on_server"
|
||||
a:text="@string/share_on_server"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:layout_marginTop="4dip"
|
||||
a:layout_marginBottom="4dip" />
|
||||
a:text="@string/share_on_server" />
|
||||
|
||||
<TextView
|
||||
a:id="@+id/commentHeading"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:layout_marginTop="8dip"
|
||||
a:labelFor="@id/share_description"
|
||||
a:text="@string/share_comment"
|
||||
a:theme="@style/Ultrasonic.AllCapsLabel"
|
||||
a:id="@+id/textViewComment"
|
||||
a:layout_gravity="center_horizontal"/>
|
||||
a:theme="@style/Ultrasonic.AllCapsLabel" />
|
||||
|
||||
<EditText
|
||||
a:id="@+id/share_description"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="wrap_content"
|
||||
a:inputType="text"
|
||||
a:singleLine="false"
|
||||
a:layout_height="50dp"
|
||||
a:layout_marginTop="4dip"
|
||||
a:layout_marginBottom="4dip" />
|
||||
a:layout_marginBottom="16dip"
|
||||
a:inputType="text"
|
||||
a:singleLine="false" />
|
||||
|
||||
|
||||
<TextView
|
||||
a:id="@+id/expirationHeading"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:labelFor="@id/date_picker"
|
||||
a:text="@string/settings.share_expiration"
|
||||
a:theme="@style/Ultrasonic.AllCapsLabel"
|
||||
a:id="@+id/textViewExpiration"
|
||||
a:layout_gravity="center_horizontal"/>
|
||||
a:theme="@style/Ultrasonic.AllCapsLabel" />
|
||||
|
||||
|
||||
<org.moire.ultrasonic.util.TimeSpanPicker
|
||||
a:id="@+id/date_picker"
|
||||
a:layout_width="match_parent"
|
||||
a:layout_height="wrap_content" />
|
||||
|
||||
|
||||
<TextView
|
||||
a:id="@+id/optionsHeading"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:layout_marginTop="4dip"
|
||||
a:layout_marginBottom="4dip" />
|
||||
a:layout_marginTop="16dip"
|
||||
a:labelFor="@id/share_description"
|
||||
a:text="@string/settings.share_options"
|
||||
a:theme="@style/Ultrasonic.AllCapsLabel" />
|
||||
|
||||
<CheckBox
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
a:id="@+id/hide_dialog"
|
||||
a:text="@string/do_not_show_dialog_again"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:layout_marginTop="4dip"
|
||||
a:layout_marginBottom="4dip" />
|
||||
a:layout_marginTop="0dp"
|
||||
a:layout_marginBottom="0dp"
|
||||
a:text="@string/do_not_show_dialog_again" />
|
||||
|
||||
<CheckBox
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
a:id="@+id/save_as_defaults"
|
||||
a:text="@string/save_as_defaults"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="wrap_content"
|
||||
a:layout_marginTop="4dip"
|
||||
a:layout_marginBottom="4dip" />
|
||||
a:layout_marginTop="0dp"
|
||||
a:layout_marginBottom="8dp"
|
||||
a:text="@string/save_as_defaults" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
@ -3,11 +3,11 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<CheckBox
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<EditText
|
||||
android:id="@+id/timeSpanEditText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentLeft="true"
|
||||
@ -38,9 +38,10 @@
|
||||
android:inputType="numberSigned" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/timeSpanSpinner"
|
||||
android:id="@+id/timeSpanUnitSelector"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_alignTop="@+id/timeSpanEditText"
|
||||
android:layout_alignBottom="@+id/timeSpanEditText"
|
||||
android:layout_toEndOf="@+id/timeSpanEditText" />
|
||||
</RelativeLayout>
|
||||
|
@ -232,7 +232,7 @@
|
||||
android:name="org.moire.ultrasonic.fragment.BookmarksFragment" />
|
||||
<fragment
|
||||
android:id="@+id/chatFragment"
|
||||
android:name="org.moire.ultrasonic.fragment.ChatFragment" />
|
||||
android:name="org.moire.ultrasonic.fragment.legacy.ChatFragment" />
|
||||
<fragment
|
||||
android:id="@+id/podcastFragment"
|
||||
android:name="org.moire.ultrasonic.fragment.legacy.PodcastFragment">
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Načítám…</string>
|
||||
<string name="background_task.network_error">Chyba sítě. Ověřte adresu serveru nebo zkuste později.</string>
|
||||
<string name="background_task.unsupported_api">API serveru v%1$s nepodporuje tuto funkci.</string>
|
||||
<string name="background_task.no_network">Tento program vyžaduje síťové připojení. Zapněte Wi-Fi nebo mobilní připojení.</string>
|
||||
@ -9,7 +8,6 @@
|
||||
<string name="background_task.parse_error">Nesrozumitelná odpověď. Ověřte adresu serveru.</string>
|
||||
<string name="background_task.ssl_cert_error">Chyba HTTPS certifikátu: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Vyjímka SSL připojení. Ověřte certifikát serveru.</string>
|
||||
<string name="background_task.wait">Chvilku strpení…</string>
|
||||
<string name="button_bar.bookmarks">Záložky</string>
|
||||
<string name="button_bar.browse">Knihovna médií</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -188,8 +186,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Neomezené</string>
|
||||
<string name="settings.max_bitrate_wifi">Max Bitrate - wi-fi</string>
|
||||
<string name="settings.max_songs">Maximum skladeb</string>
|
||||
<string name="settings.media_button_summary">Odpovídat na tlačítka ovládání médií telefonu, sluchátek a bluetooth</string>
|
||||
<string name="settings.media_button_title">Tlačítka médií</string>
|
||||
<string name="settings.network_timeout">Čas vypršení připojení</string>
|
||||
<string name="settings.network_timeout_105000">105 sekund</string>
|
||||
<string name="settings.network_timeout_120000">120 sekund</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Lade…</string>
|
||||
<string name="background_task.network_error">Ein Netzwerkfehler ist aufgetreten. Bitte die Serveradresse prüfen oder später noch einmal versuchen.</string>
|
||||
<string name="background_task.unsupported_api">Server API v%1$s unterstützt diese Funktion nicht.</string>
|
||||
<string name="background_task.no_network">Dieses Programm benötigt eine Netzwerkverbindung. Bitte das WLAN oder Mobilfunk einschalten.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">Antwort nicht verstanden. Bitte die Serveradresse überprüfen.</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS Zertifikatsfehler: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL Verbindungsfehler. Bitte das Serverzertifikat überprüfen.</string>
|
||||
<string name="background_task.wait">Bitte warten…</string>
|
||||
<string name="button_bar.bookmarks">Lesezeichen</string>
|
||||
<string name="button_bar.browse">Medienbibliothek</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -230,8 +228,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Unbegrenzt</string>
|
||||
<string name="settings.max_bitrate_wifi">Max. Bitrate - WLAN</string>
|
||||
<string name="settings.max_songs">Max. Anzahl der Titel</string>
|
||||
<string name="settings.media_button_summary">Auf Telefon, Headset und Bluetooth-Media-Tasten reagieren</string>
|
||||
<string name="settings.media_button_title">Medien Tasten</string>
|
||||
<string name="settings.network_timeout">Netzwerk Zeitüberschreitung</string>
|
||||
<string name="settings.network_timeout_105000">105 Sekunden</string>
|
||||
<string name="settings.network_timeout_120000">120 Sekunden</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Cargando…</string>
|
||||
<string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string>
|
||||
<string name="background_task.unsupported_api">La API del servidor v%1$s no admite esta función.</string>
|
||||
<string name="background_task.no_network">Este programa requiere acceso a la red. Por favor enciende la Wi-Fi o la red móvil.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">No se entiende la respuesta. Por favor comprueba la dirección del servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Error del certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Excepción de conexión SSL. Compruebe el certificado del servidor.</string>
|
||||
<string name="background_task.wait">Por favor espera…</string>
|
||||
<string name="button_bar.bookmarks">Marcadores</string>
|
||||
<string name="button_bar.browse">Biblioteca</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -232,8 +230,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
|
||||
<string name="settings.max_bitrate_wifi">Bitrate máximo - Wi-Fi</string>
|
||||
<string name="settings.max_songs">Máximo de Canciones</string>
|
||||
<string name="settings.media_button_summary">Responder a los botones multimedia del dispositivo, auriculares y Bluetooth</string>
|
||||
<string name="settings.media_button_title">Botones multimedia</string>
|
||||
<string name="settings.network_timeout">Tiempo de espera de la red</string>
|
||||
<string name="settings.network_timeout_105000">105 segundos</string>
|
||||
<string name="settings.network_timeout_120000">120 segundos</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Chargement…</string>
|
||||
<string name="background_task.network_error">Une erreur réseau est survenue. Veuillez vérifier l\'adresse du serveur ou réessayer plus tard.</string>
|
||||
<string name="background_task.unsupported_api">L’API v%1$s du serveur ne supporte pas cette fonction.</string>
|
||||
<string name="background_task.no_network">Cette application requiert un accès au réseau. Veuillez activer le Wi-Fi ou le réseau mobile.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">Réponse incorrecte. Veuillez vérifier l\'adresse du serveur.</string>
|
||||
<string name="background_task.ssl_cert_error">Erreur de certificat HTTPS : %1$s.</string>
|
||||
<string name="background_task.ssl_error">Erreur de connexion SSL. Veuillez vérifier le certificat du serveur.</string>
|
||||
<string name="background_task.wait">Veuillez patienter…</string>
|
||||
<string name="button_bar.bookmarks">Signets</string>
|
||||
<string name="button_bar.browse">Bibliothèque musicale</string>
|
||||
<string name="button_bar.chat">Salon de discussion</string>
|
||||
@ -225,8 +223,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Illimité</string>
|
||||
<string name="settings.max_bitrate_wifi">Débit maximal - Wi-Fi</string>
|
||||
<string name="settings.max_songs">Titres maximum</string>
|
||||
<string name="settings.media_button_summary">Répondre au boutons média de l\'appareil, du casque et du Bluetooth</string>
|
||||
<string name="settings.media_button_title">Boutons média</string>
|
||||
<string name="settings.network_timeout">Délai d\'attente de connexion</string>
|
||||
<string name="settings.network_timeout_105000">105 secondes</string>
|
||||
<string name="settings.network_timeout_120000">120 secondes</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="background_task.loading">Cargando…</string>
|
||||
<string name="background_task.network_error">Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde.</string>
|
||||
<string name="background_task.unsupported_api">A API do servidor v%1$s non admite esta función.</string>
|
||||
<string name="background_task.no_network">Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">Non se entende a resposta. Por favor comproba a dirección do servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Erro do certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Excepción de conexión SSL. Comprobe o certificado do servidor.</string>
|
||||
<string name="background_task.wait">Por favor agarde…</string>
|
||||
<string name="button_bar.browse">Biblioteca</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
<string name="button_bar.now_playing">Reproducindo agora</string>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Betöltés…</string>
|
||||
<string name="background_task.network_error">Hálózati hiba történt! Kérjük, ellenőrizze a kiszolgáló címét vagy próbálja később!</string>
|
||||
<string name="background_task.unsupported_api">A v%1$s verziójú Szerver api nem támogatja ezt a funkciót.</string>
|
||||
<string name="background_task.no_network">Az alkalmazás hálózati hozzáférést igényel. Kérjük, kapcsolja be a Wi-Fi-t vagy a mobilhálózatot!</string>
|
||||
@ -9,7 +8,6 @@
|
||||
<string name="background_task.parse_error">Értelmezhetetlen válasz! Kérjük, ellenőrizze a kiszolgáló címét!</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS tanúsítványhiba: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL kapcsolat kivétel. Kérjük, ellenőrizze a szerver tanúsítványát.</string>
|
||||
<string name="background_task.wait">Kérem várjon!…</string>
|
||||
<string name="button_bar.bookmarks">Könyvjelzők</string>
|
||||
<string name="button_bar.browse">Médiakönyvtár</string>
|
||||
<string name="button_bar.chat">Csevegés (Chat)</string>
|
||||
@ -194,8 +192,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Korlátlan</string>
|
||||
<string name="settings.max_bitrate_wifi">Max. bitráta - Wi-Fi kapcsolat</string>
|
||||
<string name="settings.max_songs">Dalok max. találati száma</string>
|
||||
<string name="settings.media_button_summary">Telefon irányítása a Bluetooth eszköz, vagy a fülhallgató vezérlőgombjaival.</string>
|
||||
<string name="settings.media_button_title">Média vezérlőgombok</string>
|
||||
<string name="settings.network_timeout">Hálózati időtúllépés</string>
|
||||
<string name="settings.network_timeout_105000">105 másodperc</string>
|
||||
<string name="settings.network_timeout_120000">120 másodperc</string>
|
||||
|
@ -1,13 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Caricamento…</string>
|
||||
<string name="background_task.network_error">Si è verificato un errore di rete. Si prega di controllare l\'indirizzo del server o riprovare più tardi.</string>
|
||||
<string name="background_task.no_network">Questo programma richiede l\'accesso alla rete. Per favore, abilita la connessione Wi-FI o la rete mobile.</string>
|
||||
<string name="background_task.not_found">Risorsa non trovata. Si prega di controllare l\'indirizzo del server.</string>
|
||||
<string name="background_task.parse_error">Risposta non comprensibile. Si prega di controllare l\'indirizzo del server.</string>
|
||||
<string name="background_task.ssl_cert_error">Errore certificato HTTPS. %1$s.</string>
|
||||
<string name="background_task.ssl_error">Anomalia connessione SSL. Si prega di controllare il certificato del server.</string>
|
||||
<string name="background_task.wait">Attendere per favore#8230;</string>
|
||||
<string name="button_bar.bookmarks">Segnalibri</string>
|
||||
<string name="button_bar.browse">Libreria multimediale</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -184,8 +182,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Illimitato</string>
|
||||
<string name="settings.max_bitrate_wifi">Bitrate Max - Wi-Fi</string>
|
||||
<string name="settings.max_songs">N° Max Canzoni</string>
|
||||
<string name="settings.media_button_summary">Controlli per risposta alle chiamate, per cuffie e Bluetooth</string>
|
||||
<string name="settings.media_button_title">Tasti Media</string>
|
||||
<string name="settings.network_timeout">Timeout Rete</string>
|
||||
<string name="settings.network_timeout_105000">105 seconds</string>
|
||||
<string name="settings.network_timeout_120000">120 seconds</string>
|
||||
|
@ -1,11 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">読み込み中…</string>
|
||||
<string name="background_task.network_error">ネットワークエラーが発生しました。サーバーのアドレスを確認してやり直してください。</string>
|
||||
<string name="background_task.parse_error">応答が確認できません。サーバーのアドレスを確認してください。</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS証明書エラー: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL接続が異常です。サーバーの証明書を確認してください。</string>
|
||||
<string name="background_task.wait">お待ち下さい…</string>
|
||||
<string name="button_bar.bookmarks">ブックマーク</string>
|
||||
<string name="button_bar.browse">メディアライブラリ</string>
|
||||
<string name="button_bar.chat">チャット</string>
|
||||
@ -181,8 +179,6 @@
|
||||
<string name="settings.max_bitrate_64">64 Kbps</string>
|
||||
<string name="settings.max_bitrate_80">80 Kbps</string>
|
||||
<string name="settings.max_bitrate_96">96 Kbps</string>
|
||||
<string name="settings.media_button_summary">端末本体、ヘッドセットやBluetoothの再生コントロールボタンに対応します</string>
|
||||
<string name="settings.media_button_title">メディアボタン</string>
|
||||
<string name="settings.network_timeout_15000">15秒</string>
|
||||
<string name="settings.network_timeout_75000">75秒</string>
|
||||
<string name="settings.network_timeout_90000">90秒</string>
|
||||
|
@ -37,7 +37,6 @@
|
||||
<string name="buttons.stop">Stopp</string>
|
||||
<string name="button_bar.search">Søk</string>
|
||||
<string name="chat.send_a_message">Send en melding</string>
|
||||
<string name="background_task.loading">Laster inn …</string>
|
||||
<string name="common.appname">Ultrasonic</string>
|
||||
<string name="button_bar.browse">Mediebibliotek</string>
|
||||
<string name="buttons.play">Spill</string>
|
||||
@ -137,7 +136,6 @@
|
||||
<string name="background_task.not_found">Fant ikke ressursen. Sjekk tjeneradressen.</string>
|
||||
<string name="background_task.parse_error">Forsto ikke svaret. Sjekk tjeneradressen.</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS-sertifikatsfeil: %1$s.</string>
|
||||
<string name="background_task.wait">Vent …</string>
|
||||
<string name="button_bar.bookmarks">Bokmerker</string>
|
||||
<string name="button_bar.now_playing">Spilles nå</string>
|
||||
<string name="podcasts_channels.empty">Ingen nettradioopptakskanaler registrert.</string>
|
||||
@ -219,7 +217,6 @@
|
||||
<string name="settings.invalid_url">Angi en gyldig nettadresse.</string>
|
||||
<string name="settings.max_artists">Maks. artister</string>
|
||||
<string name="settings.max_bitrate_unlimited">Ubegrenset</string>
|
||||
<string name="settings.media_button_title">Medieknapper</string>
|
||||
<string name="settings.network_timeout">Nettverkstidsavbrudd</string>
|
||||
<string name="settings.network_timeout_90000">90 sekunder</string>
|
||||
<string name="settings.network_timeout_75000">75 sekunder</string>
|
||||
@ -317,7 +314,6 @@
|
||||
<string name="menu.deleted_playlist_error">Klarte ikke å slette %s-spillelisten</string>
|
||||
<string name="settings.download_transition_summary">Bytt til «Spilles nå» etter at avspilling startes i medievisning</string>
|
||||
<string name="settings.hide_media_toast">Trer i effekt neste gang Android skanner enheten din for musikk.</string>
|
||||
<string name="settings.media_button_summary">Svarer på enhets-, hodesett, og Blåtannsmedieknapper</string>
|
||||
<string name="settings.wifi_required_summary">Kun last ned på ubegrensede tilkoblinger</string>
|
||||
<string name="notification.downloading_title">Last ned medier i bakgrunnen …\?</string>
|
||||
<string name="notification.permission_required">Merknader kreves for medieavspilling. Du kan innvilge tilgangen når som helst i Android-innstillingene.</string>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Bezig met laden…</string>
|
||||
<string name="background_task.network_error">Er is een netwerkfout opgetreden. Controleer het serveradres of probeer het later opnieuw.</string>
|
||||
<string name="background_task.unsupported_api">De server-api, v %1$s, heeft geen ondersteuning voor deze functie.</string>
|
||||
<string name="background_task.no_network">Deze app vereist netwerktoegang. Schakel wifi of mobiel internet in.</string>
|
||||
@ -9,7 +8,6 @@
|
||||
<string name="background_task.parse_error">Het antwoord werd niet begrepen. Controleer het serveradres.</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS-certificaatfout: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL-verbindingsuitzondering. Controleer het servercertificaat.</string>
|
||||
<string name="background_task.wait">Even geduld…</string>
|
||||
<string name="button_bar.bookmarks">Bladwijzers</string>
|
||||
<string name="button_bar.browse">Mediabibliotheek</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -233,8 +231,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Ongelimiteerd</string>
|
||||
<string name="settings.max_bitrate_wifi">Max. bitsnelheid via wifi</string>
|
||||
<string name="settings.max_songs">Max. aantal nummers</string>
|
||||
<string name="settings.media_button_summary">Reageren op telefoon-, headset- en bluetooth-mediatoetsen.</string>
|
||||
<string name="settings.media_button_title">Mediatoetsen</string>
|
||||
<string name="settings.network_timeout">Netwerktime-out</string>
|
||||
<string name="settings.network_timeout_105000">105 seconden</string>
|
||||
<string name="settings.network_timeout_120000">120 seconden</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Ładowanie…</string>
|
||||
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera lub spróbować później.</string>
|
||||
<string name="background_task.unsupported_api">API serwera w wersji v%1$s nie wspiera tej funkcjonalności.</string>
|
||||
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć Wi-Fi lub dane komórkowe.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>
|
||||
<string name="background_task.ssl_cert_error">Błąd certyfikatu HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera.</string>
|
||||
<string name="background_task.wait">Proszę czekać…</string>
|
||||
<string name="button_bar.bookmarks">Zakładki</string>
|
||||
<string name="button_bar.browse">Biblioteka</string>
|
||||
<string name="button_bar.chat">Czat</string>
|
||||
@ -187,8 +185,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Bez limitu</string>
|
||||
<string name="settings.max_bitrate_wifi">Maksymalny bitrate dla połączenia Wi-Fi</string>
|
||||
<string name="settings.max_songs">Maksymalna ilość wyników - utwory</string>
|
||||
<string name="settings.media_button_summary">Reaguj na przyciski multimedialne telefonu, słuchawek i urządzeń Bluetooth</string>
|
||||
<string name="settings.media_button_title">Przyciski</string>
|
||||
<string name="settings.network_timeout">Przekroczenie limitu czasu sieci</string>
|
||||
<string name="settings.network_timeout_105000">105 sekund</string>
|
||||
<string name="settings.network_timeout_120000">120 sekund</string>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Carregando…</string>
|
||||
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
|
||||
<string name="background_task.unsupported_api">O servidor api v%1$s não tem suporte para esta função.</string>
|
||||
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
|
||||
@ -9,7 +8,6 @@
|
||||
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Erro de certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Exceção de conexão SSL. Verifique o certificado do servidor.</string>
|
||||
<string name="background_task.wait">Por favor aguarde…</string>
|
||||
<string name="button_bar.bookmarks">Favoritos</string>
|
||||
<string name="button_bar.browse">Biblioteca de Mídia</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -231,8 +229,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
|
||||
<string name="settings.max_bitrate_wifi">Taxa Máxima de Bits - Wi-Fi</string>
|
||||
<string name="settings.max_songs">Máximo de Músicas</string>
|
||||
<string name="settings.media_button_summary">Obedecer aos botões do celular, fones e botões de mídia do Bluetooth</string>
|
||||
<string name="settings.media_button_title">Botões de Mídia</string>
|
||||
<string name="settings.network_timeout">Timeout da Rede</string>
|
||||
<string name="settings.network_timeout_105000">105 segundos</string>
|
||||
<string name="settings.network_timeout_120000">120 segundos</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">Carregando…</string>
|
||||
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
|
||||
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
|
||||
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Erro de certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Exceção de conexão SSL. Verifique o certificado do servidor.</string>
|
||||
<string name="background_task.wait">Por favor aguarde…</string>
|
||||
<string name="button_bar.bookmarks">Favoritos</string>
|
||||
<string name="button_bar.browse">Biblioteca de Mídia</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
@ -187,8 +185,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
|
||||
<string name="settings.max_bitrate_wifi">Máx. de Taxa de Bits - Wi-Fi</string>
|
||||
<string name="settings.max_songs">Máximo de Músicas</string>
|
||||
<string name="settings.media_button_summary">Obedecer aos botões do telemóvel, auricular e botões de mídia do Bluetooth</string>
|
||||
<string name="settings.media_button_title">Botões de Mídia</string>
|
||||
<string name="settings.network_timeout">Timeout da Rede</string>
|
||||
<string name="settings.network_timeout_105000">105 segundos</string>
|
||||
<string name="settings.network_timeout_120000">120 segundos</string>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Загрузка…</string>
|
||||
<string name="background_task.network_error">Произошла ошибка сети. Пожалуйста, проверьте адрес сервера или повторите попытку позже.</string>
|
||||
<string name="background_task.unsupported_api">Серверный api версии %1$s не поддерживает эту функцию.</string>
|
||||
<string name="background_task.no_network">Эта программа требует доступа к сети. Пожалуйста, включите Wi-Fi или мобильную сеть.</string>
|
||||
@ -9,7 +8,6 @@
|
||||
<string name="background_task.parse_error">Не понятный ответ. Пожалуйста, проверьте адрес сервера.</string>
|
||||
<string name="background_task.ssl_cert_error">Ошибка сертификата HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Исключение SSL-соединения. Пожалуйста, проверьте сертификат сервера.</string>
|
||||
<string name="background_task.wait">Пожалуйста, подождите…</string>
|
||||
<string name="button_bar.bookmarks">Закладки</string>
|
||||
<string name="button_bar.browse">Медиа библиотека</string>
|
||||
<string name="button_bar.chat">Чат</string>
|
||||
@ -212,8 +210,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">Неограниченный</string>
|
||||
<string name="settings.max_bitrate_wifi">Максимальный битрейт - Wi-Fi подключение</string>
|
||||
<string name="settings.max_songs">Максимум треков</string>
|
||||
<string name="settings.media_button_summary">Отвечать на телефон, гарнитуру и мультимедийные кнопки Bluetooth</string>
|
||||
<string name="settings.media_button_title">Медиа кнопки</string>
|
||||
<string name="settings.network_timeout">Таймаут сети</string>
|
||||
<string name="settings.network_timeout_105000">105 секунд</string>
|
||||
<string name="settings.network_timeout_120000">120 секунд</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">加载中…</string>
|
||||
<string name="background_task.network_error">发生网络错误。请检查服务器地址或稍后重试。</string>
|
||||
<string name="background_task.unsupported_api">服务端 API v%1$s 不支持此功能。</string>
|
||||
<string name="background_task.no_network">此软件需要连接网络,请打开 Wi-Fi 或移动网络。</string>
|
||||
@ -8,7 +7,6 @@
|
||||
<string name="background_task.parse_error">未知回复内容,请检查服务器地址。</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS 证书错误:%1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL 连接异常。请检查服务器证书。</string>
|
||||
<string name="background_task.wait">请稍等…</string>
|
||||
<string name="button_bar.bookmarks">书签</string>
|
||||
<string name="button_bar.browse">媒体库</string>
|
||||
<string name="button_bar.chat">聊天</string>
|
||||
@ -218,8 +216,6 @@
|
||||
<string name="settings.max_bitrate_unlimited">不限制</string>
|
||||
<string name="settings.max_bitrate_wifi">最大比特率 - WIFI</string>
|
||||
<string name="settings.max_songs">最大歌曲</string>
|
||||
<string name="settings.media_button_summary">响应手机、耳机和蓝牙设备的媒体按钮</string>
|
||||
<string name="settings.media_button_title">媒体按钮</string>
|
||||
<string name="settings.network_timeout">网络超时</string>
|
||||
<string name="settings.network_timeout_105000">105 秒</string>
|
||||
<string name="settings.network_timeout_120000">120 秒</string>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">載入中…</string>
|
||||
<string name="button_bar.bookmarks">書籤</string>
|
||||
<string name="button_bar.browse">媒體庫</string>
|
||||
<string name="button_bar.now_playing">正在播放</string>
|
||||
@ -146,7 +145,6 @@
|
||||
<string name="language.nl">荷蘭語</string>
|
||||
<string name="download.jukebox_off">已關閉遠端控制,音樂將在手機上播放。</string>
|
||||
<string name="language.de">德語</string>
|
||||
<string name="background_task.wait">請稍候…</string>
|
||||
<string name="common.unpin">取消固定</string>
|
||||
<string name="download.playlist_name">輸入播放清單名稱:</string>
|
||||
<string name="main.albums_by_year">依照時間排列</string>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user