Merge remote-tracking branch 'origin/develop' into 480

This commit is contained in:
tzugen 2023-10-18 12:44:01 +02:00
commit 601d0ccdaa
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
103 changed files with 1832 additions and 2388 deletions

View File

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

View File

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

View File

@ -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.&lt;no name provided&gt;$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&lt;ServerSetting&gt;, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -&gt; Unit), private val serverEditRequestedCallback: ((Int) -&gt; 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>

View File

@ -70,50 +70,6 @@
column="1"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/chat.xml"
line="33"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/save_playlist.xml"
line="9"
column="6"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="29"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;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`"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&#8230;</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í&#8230;</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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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&#8230;</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!&#8230;</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>

View File

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

View File

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

View File

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

View File

@ -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&#8230;</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&#8230;</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>

View File

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

View File

@ -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&#8230;</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&#8230;</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>

View File

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

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Загрузка&#8230;</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">Пожалуйста, подождите&#8230;</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>

View File

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

View File

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