Migrate remaining Java Code and modernize it

This commit is contained in:
birdbird 2023-10-18 10:19:10 +00:00
parent de523a6451
commit 442f622b35
52 changed files with 787 additions and 1206 deletions

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

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

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

View File

@ -73,7 +73,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>(), Koi
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>(), Koi
* 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)

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

@ -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
@ -23,7 +21,6 @@ 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
@ -34,18 +31,18 @@ import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.model.ServerSettingsModel
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> : ScopeFragment() {
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 var refreshListView: SwipeRefreshLayout? = null
override var swipeRefresh: SwipeRefreshLayout? = null
internal var listView: RecyclerView? = null
internal lateinit var viewManager: LinearLayoutManager
internal lateinit var emptyView: ConstraintLayout
@ -95,16 +92,6 @@ abstract class MultiListFragment<T : Identifiable> : ScopeFragment() {
*/
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(
@ -122,7 +109,7 @@ abstract class MultiListFragment<T : Identifiable> : ScopeFragment() {
* 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)
}
@ -130,9 +117,9 @@ abstract class MultiListFragment<T : Identifiable> : ScopeFragment() {
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

@ -92,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
@ -778,11 +778,8 @@ class PlayerFragment :
return true
}
R.id.menu_item_share -> {
val tracks: MutableList<Track?> = ArrayList()
val playlist = mediaPlayerManager.playlist
for (item in playlist) {
val playlistEntry = item.toTrack()
tracks.add(playlistEntry)
val tracks = mediaPlayerManager.playlist.map {
it.toTrack()
}
shareHandler.createShare(
this,
@ -793,12 +790,9 @@ class PlayerFragment :
R.id.menu_item_share_song -> {
if (track == null) return true
val tracks: MutableList<Track?> = ArrayList()
tracks.add(track)
shareHandler.createShare(
this,
tracks,
listOf(track),
)
return true
}

View File

@ -34,39 +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.MediaPlayerManager
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>(), KoinScopeComponent {
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 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(
@ -79,8 +72,8 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinScopeComponent {
}
}
searchRefresh = view.findViewById(R.id.swipe_refresh_view)
searchRefresh!!.isEnabled = false
swipeRefresh = view.findViewById(R.id.swipe_refresh_view)
swipeRefresh!!.isEnabled = false
registerForContextMenu(listView!!)
@ -129,17 +122,17 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinScopeComponent {
override fun onDestroyView() {
Util.hideKeyboard(activity)
cancellationToken?.cancel()
super.onDestroyView()
}
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()
}
}

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

View File

@ -41,7 +41,7 @@ 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
@ -56,6 +56,7 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.Settings
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
@ -107,8 +108,8 @@ open class TrackCollectionFragment(
albumButtons = view.findViewById(R.id.menu_album)
// Setup refresh handler
refreshListView = view.findViewById(refreshListId)
refreshListView?.setOnRefreshListener {
swipeRefresh = view.findViewById(refreshListId)
swipeRefresh?.setOnRefreshListener {
handleRefresh()
}
@ -284,7 +285,7 @@ open class TrackCollectionFragment(
} else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare(
fragment = this@TrackCollectionFragment,
tracks = getSelectedTracks(),
tracks = getSelectedOrAllTracks(),
additionalId = navArgs.id
)
return true
@ -338,7 +339,7 @@ open class TrackCollectionFragment(
} else {
mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName
mediaPlayerManager.addToPlaylist(
songs = getAllSongs(),
songs = getAllTracks(),
insertionMode = insertionMode,
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
shuffle = shuffle
@ -357,26 +358,20 @@ open class TrackCollectionFragment(
}
private fun downloadSelectedOrAllTracks(save: Boolean) {
var tracks = getSelectedTracks()
if (tracks.isEmpty()) tracks = getAllSongs()
DownloadUtil.justDownload(
action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD,
fragment = this,
tracks = tracks
tracks = getSelectedOrAllTracks()
)
}
private fun playSelectedOrAllTracks(
insertionMode: MediaPlayerManager.InsertionMode
) {
var tracks = getSelectedTracks()
if (tracks.isEmpty()) tracks = getAllSongs()
mediaPlayerManager.playTracksAndToast(
fragment = this,
insertionMode = insertionMode,
tracks = tracks
tracks = getSelectedOrAllTracks()
)
}
@ -388,13 +383,6 @@ open class TrackCollectionFragment(
)
}
@Suppress("UNCHECKED_CAST")
private fun getAllSongs(): List<Track> {
return viewAdapter.getCurrentList().filter {
it is Track && !it.isDirectory
} as List<Track>
}
private fun selectAllOrNone() {
val someUnselected = viewAdapter.selectedSet.size < childCount
selectAll(someUnselected)
@ -503,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)
}
@ -534,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!!)
@ -570,7 +573,7 @@ open class TrackCollectionFragment(
}
}
refreshListView?.isRefreshing = false
swipeRefresh?.isRefreshing = false
}
return listModel.currentList
}

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,48 +25,46 @@ import android.widget.CheckBox
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
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.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.DownloadAction
import org.moire.ultrasonic.util.DownloadUtil
import org.moire.ultrasonic.util.FragmentBackgroundTask
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 : ScopeFragment(), KoinScopeComponent {
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 var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
applyTheme(this.context)
super.onCreate(savedInstanceState)
applyTheme(requireContext())
}
override fun onCreateView(
@ -78,17 +76,16 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
}
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,
)
@ -99,33 +96,25 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
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)
val cacheCleaner: CacheCleaner by inject()
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?) {
@ -143,7 +132,7 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
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 -> {
DownloadUtil.justDownload(
@ -214,57 +203,45 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
.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(
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(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)
@ -293,42 +270,31 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
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(
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(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,29 +24,29 @@ import android.widget.CheckBox
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
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.util.BackgroundTask
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.DownloadAction
import org.moire.ultrasonic.util.DownloadUtil
import org.moire.ultrasonic.util.FragmentBackgroundTask
import org.moire.ultrasonic.util.LoadingTask
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
/**
@ -54,16 +54,16 @@ import org.moire.ultrasonic.view.ShareAdapter
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class SharesFragment : ScopeFragment(), KoinScopeComponent {
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 mediaPlayerManager: MediaPlayerManager by inject()
private var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
super.onCreate(savedInstanceState)
Util.applyTheme(requireContext())
}
override fun onCreateView(
@ -75,48 +75,41 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent {
}
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(
@ -202,45 +195,34 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent {
.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()
toast(
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)
)
}
toast(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)
@ -255,18 +237,18 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent {
(
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)
@ -297,49 +279,31 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent {
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)
toast(
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)
)
}
toast(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

@ -197,6 +197,7 @@ class ImageLoader(
* Download a cover art file of a Track and cache it on disk
*/
fun downloadCoverArt(track: Track) {
if (track.coverArt == null) return
downloadCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
}

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

@ -58,6 +58,7 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent {
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.downloadCoverArt(parts[0], albumArtFile)
}

View File

@ -510,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.downloadCoverArt(this)
imageLoader.downloadCoverArt(this@cacheMetadataAndArtwork)
// Cache small copies of the Artist picture
directArtist?.let { imageLoader.cacheArtistPicture(it) }
compilationArtist?.let { imageLoader.cacheArtistPicture(it) }

View File

@ -11,7 +11,6 @@ import android.os.Handler
import android.os.Looper
import androidx.annotation.IntRange
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
@ -502,9 +501,8 @@ class MediaPlayerManager(
shuffle: Boolean = false,
isArtist: Boolean = false
) {
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
scope.launchWithToast {
fragment.launchWithToast {
val list: List<Track> =
tracks.ifEmpty {

View File

@ -12,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
@ -55,7 +56,7 @@ class ImageLoaderProvider :
}
fun executeOn(cb: (iL: ImageLoader) -> Unit) {
launch {
launch(CoroutinePatterns.loggingExceptionHandler) {
val iL = getImageLoader()
withContext(Dispatchers.Main) {
cb(iL)

View File

@ -68,14 +68,14 @@ class ShareHandler {
val ids: MutableList<String> = ArrayList()
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1)
if (!shareDetails.shareOnServer && shareDetails.entries.size == 1)
return@withContext null
if (shareDetails.Entries.isEmpty()) {
if (shareDetails.entries.isEmpty()) {
additionalId.ifNotNull {
ids.add(it)
}
} else {
for ((id) in shareDetails.Entries) {
for ((id) in shareDetails.entries) {
ids.add(id)
}
}
@ -83,15 +83,21 @@ class ShareHandler {
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)
val shares = musicService.createShare(
ids = ids,
description = shareDetails.description,
expires = timeInMillis
)
// Return the share
shares[0]
if (shares.isNotEmpty())
shares[0]
else
null
} catch (ignored: Exception) {
null
}
@ -120,15 +126,15 @@ class ShareHandler {
val textBuilder = StringBuilder()
textBuilder.appendLine(Settings.shareGreeting)
if (!shareDetails.Entries[0].title.isNullOrEmpty())
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())
.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())
.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)
.append(": ").append(shareDetails.entries[0].album)
intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString())
}
@ -144,17 +150,17 @@ class ShareHandler {
fun createShare(
fragment: Fragment,
tracks: List<Track?>?,
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, additionalId)
} else {
shareDetails.Description = Settings.defaultShareDescription
shareDetails.Expiration = System.currentTimeMillis() +
shareDetails.description = Settings.defaultShareDescription
shareDetails.expiration = System.currentTimeMillis() +
Settings.defaultShareExpirationInMillis
share(fragment, shareDetails, additionalId)
}
@ -177,12 +183,12 @@ class ShareHandler {
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
}
// Handle the visibility based on shareDetails.Entries size
if (shareDetails.Entries.size == 1) {
if (shareDetails.entries.size == 1) {
shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ ->
updateVisibility()
}
@ -238,11 +244,11 @@ class ShareHandler {
builder.setPositiveButton(R.string.menu_share) { _, _ ->
if (!noExpirationCheckBox!!.isChecked) {
val timeSpan: Long = timeSpanPicker!!.getTimeSpan()
shareDetails.Expiration = System.currentTimeMillis() + timeSpan
shareDetails.expiration = System.currentTimeMillis() + timeSpan
}
shareDetails.Description = shareDescription!!.text.toString()
shareDetails.ShareOnServer = shareOnServerCheckBox!!.isChecked
shareDetails.description = shareDescription!!.text.toString()
shareDetails.shareOnServer = shareOnServerCheckBox!!.isChecked
if (hideDialogCheckBox!!.isChecked) {
Settings.shouldAskForShareDetails = false
@ -255,8 +261,8 @@ class ShareHandler {
if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0)
String.format("%d:%s", timeSpanAmount, timeSpanType) else ""
Settings.defaultShareDescription = shareDetails.Description
Settings.shareOnServer = shareDetails.ShareOnServer
Settings.defaultShareDescription = shareDetails.description!!
Settings.shareOnServer = shareDetails.shareOnServer
}
share(fragment, shareDetails, additionalId)

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

@ -130,8 +130,7 @@ object ContextMenuUtil : KoinComponent {
val shareHandler: ShareHandler by inject()
shareHandler.createShare(
fragment = fragment,
tracks = tracks,
additionalId = null
tracks = tracks
)
}
else -> return false

View File

@ -9,13 +9,16 @@ 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.async
import kotlinx.coroutines.launch
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 {
@ -28,24 +31,43 @@ object CoroutinePatterns {
}
}
fun CoroutineScope.launchWithToast(
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 deferred = async(CoroutinePatterns.loggingExceptionHandler, block = block)
val deferred = scope.async(block = block)
// Setup a handler when the job is done
deferred.invokeOnCompletion {
val toastString = if (it != null && it !is CancellationException) {
CommunicationError.getErrorMessage(it)
getErrorMessage(it)
} else {
null
}
launch(Dispatchers.Main) {
scope.launch(Dispatchers.Main) {
val successString = toastString ?: deferred.await()
if (successString != null) {
Util.toast(successString, UApp.applicationContext())
this@launchWithToast.toast(successString)
}
}
}

View File

@ -8,7 +8,6 @@
package org.moire.ultrasonic.util
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import java.util.LinkedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -36,10 +35,8 @@ object DownloadUtil {
tracks: List<Track>? = null
) {
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
// Launch the Job
scope.launchWithToast {
fragment.launchWithToast {
val tracksToDownload: List<Track> = tracks
?: getTracksFromServerAsync(isArtist, id!!, isDirectory, name, isShare)

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

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

@ -31,7 +31,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty
AdapterView.OnItemSelectedListener {
private val timeSpanEditText: EditText
private val timeSpanSpinner: Spinner
val timeSpanDisableCheckbox: CheckBox
private val timeSpanDisableCheckbox: CheckBox
private var mTimeSpan: Long = -1L
private val adapter: ArrayAdapter<CharSequence>
private val dialog: View
@ -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

@ -30,7 +30,6 @@ 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
@ -87,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 {
@ -145,19 +143,11 @@ object Util {
// 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)
}
@ -277,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)
}
@ -299,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
@ -319,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)
}
}

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

@ -356,6 +356,7 @@
<string name="save_as_defaults">Save as default</string>
<string name="share_comment">Comment</string>
<string name="settings.share_expiration">Time To Expiration</string>
<string name="settings.share_options">Options</string>
<string name="download_song_removed">\"%s\" was removed from playlist</string>
<string name="download.share_playlist">Share Playlist</string>
<string name="download.share_song">Share Current Song</string>