Refactor Koin, Scopes & Lifecycles

This commit is contained in:
birdbird 2023-10-14 19:09:26 +00:00
parent 823465d6fd
commit 17260878ac
74 changed files with 1082 additions and 1219 deletions

View File

@ -1,11 +1,6 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

View File

@ -70,50 +70,6 @@
column="1"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/chat.xml"
line="33"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/save_playlist.xml"
line="9"
column="6"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="29"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/time_span_dialog.xml"
line="28"
column="10"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"

View File

@ -72,7 +72,7 @@
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService"
<service android:name=".service.PlaybackService"
android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
@ -107,6 +107,12 @@
<action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/>
</intent-filter>
</receiver>
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider"
android:label="Ultrasonic"
@ -119,12 +125,6 @@
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info"/>
</receiver>
<receiver android:name=".receiver.MediaButtonIntentReceiver"
android:exported="true">
<intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<provider
android:name=".provider.SearchSuggestionProvider"
android:authorities="${applicationId}.provider.SearchSuggestionProvider"

View File

@ -23,7 +23,6 @@ import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
@ -39,6 +38,7 @@ import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.onNavDestinationSelected
@ -50,6 +50,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
@ -63,7 +64,6 @@ import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.LocaleHelper
@ -81,7 +81,7 @@ import timber.log.Timber
* onCreate/onResume/onDestroy methods...
*/
@Suppress("TooManyFunctions")
class NavigationActivity : AppCompatActivity() {
class NavigationActivity : ScopeActivity() {
private var videoMenuItem: MenuItem? = null
private var chatMenuItem: MenuItem? = null
private var bookmarksMenuItem: MenuItem? = null
@ -485,15 +485,19 @@ class NavigationActivity : AppCompatActivity() {
val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(Settings.maxSongs)
val downloadHandler: DownloadHandler by inject()
downloadHandler.addTracksToMediaController(
mediaPlayerManager.addToPlaylist(
songs = musicDirectory.getTracks(),
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = false,
fragment = currentFragment,
playlistName = null
insertionMode = MediaPlayerManager.InsertionMode.CLEAR
)
if (Settings.shouldTransitionOnPlayback) {
currentFragment.findNavController().popBackStack(R.id.playerFragment, true)
currentFragment.findNavController().navigate(R.id.playerFragment)
}
return
}

View File

@ -17,6 +17,10 @@ import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
@ -63,16 +67,21 @@ class ArtistRowBinder(
if (showArtistPicture()) {
holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(item.name, false)
imageLoaderProvider.executeOn {
it.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
CoroutineScope(Dispatchers.IO).launch {
val key = FileUtil.getArtistArtKey(item.name, false)
withContext(Dispatchers.Main) {
imageLoaderProvider.executeOn {
it.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
}
}
}
} else {
holder.coverArt.visibility = View.GONE

View File

@ -31,7 +31,6 @@ import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber
const val INDICATOR_THICKNESS_INDEFINITE = 5
const val INDICATOR_THICKNESS_DEFINITE = 10
@ -79,10 +78,6 @@ class TrackViewHolder(val view: View) :
private var rxBusSubscription: CompositeDisposable? = null
init {
Timber.v("New ViewHolder created")
}
@Suppress("ComplexMethod")
fun setSong(
song: Track,

View File

@ -7,8 +7,6 @@
package org.moire.ultrasonic.data
import android.os.Handler
import android.os.Looper
import androidx.room.Room
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -106,6 +104,30 @@ class ActiveServerProvider(
}
}
/**
* Sets the Active Server by its unique id
* @param serverId: The id of the desired server
*/
fun setActiveServerById(serverId: Int) {
val oldServerId = Settings.activeServer
if (oldServerId == serverId) return
// Notify components about the change before actually resetting the MusicService
// so they can react by e.g. stopping playback on the old server
RxBus.activeServerChangingPublisher.onNext(oldServerId)
// Use a coroutine to post the server change to the end of the message queue
launch {
withContext(Dispatchers.Main) {
resetMusicService()
Settings.activeServer = serverId
RxBus.activeServerChangedPublisher.onNext(getActiveServer(serverId))
Timber.i("setActiveServerById done, new id: %s", serverId)
}
}
}
@Synchronized
fun getActiveMetaDatabase(): MetaDatabase {
val activeServer = getActiveServerId()
@ -234,29 +256,6 @@ class ActiveServerProvider(
return Settings.activeServer
}
/**
* Sets the Active Server by its unique id
* @param serverId: The id of the desired server
*/
fun setActiveServerById(serverId: Int) {
val oldServerId = Settings.activeServer
if (oldServerId == serverId) return
// Notify components about the change before actually resetting the MusicService
// so they can react by e.g. stopping playback on the old server
RxBus.activeServerChangingPublisher.onNext(oldServerId)
// Post the server change to the end of the message queue,
// so the cleanup have time to finish
Handler(Looper.getMainLooper()).post {
resetMusicService()
Settings.activeServer = serverId
RxBus.activeServerChangedPublisher.onNext(serverId)
Timber.i("setActiveServerById done, new id: %s", serverId)
}
}
/**
* Queries if Scrobbling is enabled
*/

View File

@ -1,11 +1,11 @@
/*
* CachedDataSource.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
package org.moire.ultrasonic.data
import android.net.Uri
import androidx.core.net.toUri

View File

@ -1,14 +1,15 @@
package org.moire.ultrasonic.di
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
/**
* This Koin module contains the registration of general classes needed for Ultrasonic
*/
val applicationModule = module {
single { ActiveServerProvider(get()) }
single { ImageLoaderProvider(androidContext()) }
single { ImageLoaderProvider() }
single { CacheCleaner() }
}

View File

@ -1,19 +1,27 @@
package org.moire.ultrasonic.di
import org.koin.dsl.module
import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.PlaybackStateSerializer
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
/**
* This Koin module contains the registration of classes related to the media player
*/
val mediaPlayerModule = module {
single { MediaPlayerLifecycleSupport() }
// These are dependency-free
single { PlaybackStateSerializer() }
single { ExternalStorageMonitor() }
single { NetworkAndStorageChecker() }
single { ShareHandler() }
// TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerManager(get(), get(), get()) }
scope<NavigationActivity> {
scoped { MediaPlayerManager(get(), get()) }
scoped { MediaPlayerLifecycleSupport(get(), get(), get(), get()) }
}
}

View File

@ -3,7 +3,6 @@ package org.moire.ultrasonic.di
import kotlin.math.abs
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.moire.ultrasonic.BuildConfig
@ -16,9 +15,6 @@ import org.moire.ultrasonic.service.CachedMusicService
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.OfflineMusicService
import org.moire.ultrasonic.service.RESTMusicService
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.util.Constants
/**
@ -68,8 +64,4 @@ val musicServiceModule = module {
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {
OfflineMusicService()
}
single { DownloadHandler(get(), get()) }
single { NetworkAndStorageChecker(androidContext()) }
single { ShareHandler(androidContext()) }
}

View File

@ -17,7 +17,7 @@ import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.PlaybackState
import org.moire.ultrasonic.service.MediaPlayerManager
/**
* Lists the Bookmarks available on the server
@ -61,22 +61,21 @@ class BookmarksFragment : TrackCollectionFragment() {
}
/**
* Custom playback function which uses the restore functionality. A bit of a hack..
* Play the selected tracks at the bookmarked position
*/
private fun playNow(songs: List<Track>) {
if (songs.isNotEmpty()) {
val state = PlaybackState(
mediaPlayerManager.addToPlaylist(
songs = songs,
currentPlayingIndex = 0,
currentPlayingPosition = songs[0].bookmarkPosition
autoPlay = false,
shuffle = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR
)
mediaPlayerManager.restore(
state = state,
autoPlay = true,
newPlaylist = true
)
mediaPlayerManager.seekTo(0, songs[0].bookmarkPosition)
mediaPlayerManager.prepare()
mediaPlayerManager.play()
}
}
}

View File

@ -11,10 +11,10 @@ import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.moire.ultrasonic.R
import org.koin.core.component.KoinScopeComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.adapters.FolderSelectorBinder
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
@ -23,17 +23,17 @@ import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
/**
* An extension of the MultiListFragment, with a few helper functions geared
* towards the display of MusicDirectory.Entries.
* @param T: The type of data which will be used (must extend GenericEntry)
*/
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>(), KoinScopeComponent {
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
private val mediaPlayerManager: MediaPlayerManager by inject()
/**
* Whether to show the folder selector
@ -46,7 +46,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist)
return handleContextMenu(menuItem, item, isArtist, downloadHandler, this)
return handleContextMenu(menuItem, item, isArtist, mediaPlayerManager, this)
}
override fun onItemClick(item: T) {
@ -119,65 +119,4 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
header
}
companion object {
@Suppress("LongMethod")
internal fun handleContextMenu(
menuItem: MenuItem,
item: Identifiable,
isArtist: Boolean,
downloadHandler: DownloadHandler,
fragment: Fragment
): Boolean {
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
autoPlay = true,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
autoPlay = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.justDownload(
action = DownloadAction.PIN,
fragment,
item.id,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.justDownload(
action = DownloadAction.UNPIN,
fragment,
item.id,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.justDownload(
action = DownloadAction.DOWNLOAD,
fragment,
item.id,
isArtist = isArtist
)
else -> return false
}
return true
}
}
}

View File

@ -20,7 +20,11 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import java.lang.ref.SoftReference
import org.koin.core.component.KoinComponent
import kotlin.collections.HashMap
import kotlin.collections.hashMapOf
import kotlin.collections.set
import org.koin.androidx.scope.ScopeFragment
import org.koin.core.component.KoinScopeComponent
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
@ -34,7 +38,7 @@ import org.moire.ultrasonic.view.SortOrder
import org.moire.ultrasonic.view.ViewCapabilities
import timber.log.Timber
class MainFragment : Fragment(), KoinComponent {
class MainFragment : ScopeFragment(), KoinScopeComponent {
private var filterButtonBar: FilterButtonBar? = null
private var layoutType: LayoutType = LayoutType.COVER

View File

@ -17,7 +17,6 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@ -26,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.CoroutineExceptionHandler
import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeFragment
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
@ -33,7 +33,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Util
@ -42,11 +41,10 @@ import org.moire.ultrasonic.util.Util
* An abstract Model, which can be extended to display a list of items of type T from the API
* @param T: The type of data which will be used (must extend GenericEntry)
*/
abstract class MultiListFragment<T : Identifiable> : Fragment() {
abstract class MultiListFragment<T : Identifiable> : ScopeFragment() {
internal val activeServerProvider: ActiveServerProvider by inject()
internal val serverSettingsModel: ServerSettingsModel by viewModel()
internal val imageLoaderProvider: ImageLoaderProvider by inject()
protected val downloadHandler: DownloadHandler by inject()
protected var refreshListView: SwipeRefreshLayout? = null
internal var listView: RecyclerView? = null
internal lateinit var viewManager: LinearLayoutManager

View File

@ -15,7 +15,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
@ -23,6 +22,7 @@ import io.reactivex.rxjava3.disposables.Disposable
import java.lang.Exception
import kotlin.math.abs
import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeFragment
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerManager
@ -37,7 +37,7 @@ import timber.log.Timber
/**
* Contains the mini-now playing information box displayed at the bottom of the screen
*/
class NowPlayingFragment : Fragment() {
class NowPlayingFragment : ScopeFragment() {
private var downX = 0f
private var downY = 0f

View File

@ -45,7 +45,6 @@ import androidx.core.content.res.ResourcesCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
@ -79,12 +78,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.core.component.KoinComponent
import org.koin.androidx.scope.ScopeFragment
import org.koin.core.component.KoinScopeComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
@ -106,6 +105,7 @@ import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.toast
import org.moire.ultrasonic.util.toTrack
import org.moire.ultrasonic.view.AutoRepeatButton
import timber.log.Timber
@ -116,9 +116,9 @@ import timber.log.Timber
*/
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
class PlayerFragment :
Fragment(),
ScopeFragment(),
GestureDetector.OnGestureListener,
KoinComponent,
KoinScopeComponent,
CoroutineScope by CoroutineScope(Dispatchers.Main) {
// Settings
@ -356,14 +356,14 @@ class PlayerFragment :
onPlaylistChanged()
when (newRepeat) {
0 -> Util.toast(
context, R.string.download_repeat_off
0 -> toast(
R.string.download_repeat_off
)
1 -> Util.toast(
context, R.string.download_repeat_single
1 -> toast(
R.string.download_repeat_single
)
2 -> Util.toast(
context, R.string.download_repeat_all
2 -> toast(
R.string.download_repeat_all
)
else -> {
}
@ -410,7 +410,7 @@ class PlayerFragment :
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
try {
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
jukeboxAvailable = getMusicService().isJukeboxAvailable()
} catch (all: Exception) {
Timber.e(all)
}
@ -457,9 +457,9 @@ class PlayerFragment :
val isEnabled = mediaPlayerManager.toggleShuffle()
if (isEnabled) {
Util.toast(activity, R.string.download_menu_shuffle_on)
toast(R.string.download_menu_shuffle_on)
} else {
Util.toast(activity, R.string.download_menu_shuffle_off)
toast(R.string.download_menu_shuffle_off)
}
updateShuffleButtonState(isEnabled)
@ -579,8 +579,7 @@ class PlayerFragment :
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerManager
val track = mediaPlayerController.currentMediaItem?.toTrack()
val track = mediaPlayerManager.currentMediaItem?.toTrack()
if (track != null) {
currentSong = track
@ -600,7 +599,7 @@ class PlayerFragment :
goToArtist.isVisible = false
}
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
screenOption?.setTitle(R.string.download_menu_screen_off)
} else {
screenOption?.setTitle(R.string.download_menu_screen_on)
@ -609,7 +608,7 @@ class PlayerFragment :
if (jukeboxOption != null) {
jukeboxOption.isEnabled = jukeboxAvailable
jukeboxOption.isVisible = jukeboxAvailable
if (mediaPlayerController.isJukeboxEnabled) {
if (mediaPlayerManager.isJukeboxEnabled) {
jukeboxOption.setTitle(R.string.download_menu_jukebox_off)
} else {
jukeboxOption.setTitle(R.string.download_menu_jukebox_on)
@ -707,8 +706,7 @@ class PlayerFragment :
R.id.menu_item_jukebox -> {
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
Util.toast(
context,
toast(
if (jukeboxEnabled) R.string.download_jukebox_on
else R.string.download_jukebox_off,
false
@ -760,7 +758,7 @@ class PlayerFragment :
R.string.download_bookmark_set_at_position,
bookmarkTime
)
Util.toast(context, msg)
toast(msg)
return true
}
R.id.menu_item_bookmark_delete -> {
@ -776,13 +774,12 @@ class PlayerFragment :
Timber.e(all)
}
}.start()
Util.toast(context, R.string.download_bookmark_removed)
toast(R.string.download_bookmark_removed)
return true
}
R.id.menu_item_share -> {
val mediaPlayerController = mediaPlayerManager
val tracks: MutableList<Track?> = ArrayList()
val playlist = mediaPlayerController.playlist
val playlist = mediaPlayerManager.playlist
for (item in playlist) {
val playlistEntry = item.toTrack()
tracks.add(playlistEntry)
@ -790,8 +787,6 @@ class PlayerFragment :
shareHandler.createShare(
this,
tracks = tracks,
swipe = null,
cancellationToken = cancellationToken,
)
return true
}
@ -804,8 +799,6 @@ class PlayerFragment :
shareHandler.createShare(
this,
tracks,
swipe = null,
cancellationToken = cancellationToken
)
return true
}
@ -822,7 +815,7 @@ class PlayerFragment :
}
private fun savePlaylistInBackground(playlistName: String) {
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
toast(resources.getString(R.string.download_playlist_saving, playlistName))
mediaPlayerManager.suggestedPlaylistName = playlistName
// The playlist can be acquired only from the main thread
@ -835,7 +828,7 @@ class PlayerFragment :
musicService.createPlaylist(null, playlistName, entries)
}.invokeOnCompletion {
if (it == null || it is CancellationException) {
Util.toast(UApp.applicationContext(), R.string.download_playlist_done)
toast(R.string.download_playlist_done)
} else {
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
val msg = String.format(
@ -844,7 +837,7 @@ class PlayerFragment :
resources.getString(R.string.download_playlist_error),
CommunicationError.getErrorMessage(it)
)
Util.toast(UApp.applicationContext(), msg)
toast(msg)
}
}
}
@ -958,7 +951,7 @@ class PlayerFragment :
item?.mediaMetadata?.title
)
Util.toast(context, songRemoved)
toast(songRemoved)
// Remove the item from the playlist
mediaPlayerManager.removeFromPlaylist(pos)
@ -1059,15 +1052,14 @@ class PlayerFragment :
}
private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerManager
// Try to display playlist in play order
val list = mediaPlayerController.playlistInPlayOrder
val list = mediaPlayerManager.playlistInPlayOrder
emptyTextView.setText(R.string.playlist_empty)
viewAdapter.submitList(list.map(MediaItem::toTrack))
progressIndicator.isVisible = false
emptyView.isVisible = list.isEmpty()
updateRepeatButtonState(mediaPlayerController.repeatMode)
updateRepeatButtonState(mediaPlayerManager.repeatMode)
}
private fun onTrackChanged() {

View File

@ -17,7 +17,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.KoinScopeComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumRowDelegate
@ -36,13 +36,12 @@ import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenuTracks
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.toast
@ -51,15 +50,12 @@ import org.moire.ultrasonic.util.Util.toast
* Initiates a search on the media library and displays the results
*/
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
class SearchFragment : MultiListFragment<Identifiable>(), KoinScopeComponent {
private var searchResult: SearchResult? = null
private var searchRefresh: SwipeRefreshLayout? = null
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private var cancellationToken: CancellationToken? = null
private val navArgs by navArgs<SearchFragmentArgs>()
@ -137,18 +133,6 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
super.onDestroyView()
}
private fun downloadBackground(save: Boolean, songs: List<Track?>) {
val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
DownloadService.download(
songs.filterNotNull(),
save = save,
updateSaveFlag = true
)
}
onValid.run()
}
private fun search(query: String, autoplay: Boolean) {
listModel.viewModelScope.launch(CommunicationError.getHandler(context)) {
refreshListView?.isRefreshing = true
@ -253,7 +237,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
insertionMode = MediaPlayerManager.InsertionMode.APPEND
)
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
toast(context, resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
toast(resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
}
private fun onVideoSelected(track: Track) {
@ -288,103 +272,23 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
@Suppress("LongMethod")
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
val isArtist = (item is Artist)
val found = EntryListFragment.handleContextMenu(
menuItem,
item,
isArtist,
downloadHandler,
this
)
if (found || item !is Track) return true
val songs = mutableListOf<Track>()
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = false,
fragment = this,
playlistName = null
)
}
R.id.song_menu_play_next -> {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
autoPlay = false,
shuffle = false,
fragment = this,
playlistName = null
)
}
R.id.song_menu_play_last -> {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
autoPlay = false,
shuffle = false,
fragment = this,
playlistName = null
)
}
R.id.song_menu_pin -> {
songs.add(item)
toast(
context,
resources.getQuantityString(
R.plurals.n_songs_pinned,
songs.size,
songs.size
)
)
downloadBackground(true, songs)
}
R.id.song_menu_download -> {
songs.add(item)
toast(
context,
resources.getQuantityString(
R.plurals.n_songs_to_be_downloaded,
songs.size,
songs.size
)
)
downloadBackground(false, songs)
}
R.id.song_menu_unpin -> {
songs.add(item)
toast(
context,
resources.getQuantityString(
R.plurals.n_songs_unpinned,
songs.size,
songs.size
)
)
DownloadService.unpin(songs)
}
R.id.song_menu_share -> {
songs.add(item)
shareHandler.createShare(
fragment = this,
tracks = songs,
swipe = searchRefresh,
cancellationToken = cancellationToken!!,
additionalId = null
)
}
// Here the Item could be a track or an album or an artist
if (item is Track) {
return handleContextMenuTracks(
menuItem = menuItem,
tracks = listOf(item),
mediaPlayerManager = mediaPlayerManager,
fragment = this
)
} else {
return handleContextMenu(
menuItem = menuItem,
item = item,
isArtist = item is Artist,
mediaPlayerManager = mediaPlayerManager,
fragment = this
)
}
return true
}
companion object {

View File

@ -64,7 +64,7 @@ class ServerSelectorFragment : Fragment() {
listView?.onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
val server = parent.getItemAtPosition(position) as ServerSetting
ActiveServerProvider.setActiveServerById(server.id)
activeServerProvider.setActiveServerById(server.id)
findNavController().popBackStack(R.id.mainFragment, false)
}
@ -99,7 +99,7 @@ class ServerSelectorFragment : Fragment() {
val activeServerId = ActiveServerProvider.getActiveServerId()
// If the currently active server is deleted, go offline
if (id == activeServerId) ActiveServerProvider.setActiveServerById(OFFLINE_DB_ID)
if (id == activeServerId) activeServerProvider.setActiveServerById(OFFLINE_DB_ID)
serverSettingsModel.deleteItemById(id)

View File

@ -300,7 +300,7 @@ class SettingsFragment :
SearchSuggestionProvider.MODE
)
suggestions.clearHistory()
toast(activity, R.string.settings_search_history_cleared)
toast(R.string.settings_search_history_cleared)
false
}
}
@ -332,7 +332,7 @@ class SettingsFragment :
Timber.w("Failed to delete %s", nomediaDir)
}
}
toast(activity, R.string.settings_hide_media_toast, false)
toast(R.string.settings_hide_media_toast, false)
}
private fun setCacheLocation(path: String) {

View File

@ -46,14 +46,16 @@ import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.ContextMenuUtil
import org.moire.ultrasonic.util.DownloadAction
import org.moire.ultrasonic.util.DownloadUtil
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.navigateToCurrent
import org.moire.ultrasonic.util.Util.toast
import org.moire.ultrasonic.view.SortOrder
import org.moire.ultrasonic.view.ViewCapabilities
import timber.log.Timber
@ -86,7 +88,6 @@ open class TrackCollectionFragment(
internal val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null
override val listModel: TrackCollectionModel by viewModels()
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
@ -102,7 +103,6 @@ open class TrackCollectionFragment(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
cancellationToken = CancellationToken()
albumButtons = view.findViewById(R.id.menu_album)
@ -211,19 +211,23 @@ open class TrackCollectionFragment(
}
playNowButton?.setOnClickListener {
playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true)
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.CLEAR)
}
playNextButton?.setOnClickListener {
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true)
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.AFTER_CURRENT)
}
playLastButton!!.setOnClickListener {
playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true)
playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.APPEND)
}
pinButton?.setOnClickListener {
downloadBackground(true)
downloadSelectedOrAllTracks(true)
}
downloadButton?.setOnClickListener {
downloadSelectedOrAllTracks(false)
}
unpinButton?.setOnClickListener {
@ -231,26 +235,22 @@ open class TrackCollectionFragment(
ConfirmationDialog.Builder(requireContext())
.setMessage(R.string.common_unpin_selection_confirmation)
.setPositiveButton(R.string.common_unpin) { _, _ ->
unpin()
unpinSelectedTracks()
}.show()
} else {
unpin()
unpinSelectedTracks()
}
}
downloadButton?.setOnClickListener {
downloadBackground(false)
}
deleteButton?.setOnClickListener {
if (Settings.showConfirmationDialog) {
ConfirmationDialog.Builder(requireContext())
.setMessage(R.string.common_delete_selection_confirmation)
.setPositiveButton(R.string.common_delete) { _, _ ->
delete()
deleteSelectedTracks()
}.show()
} else {
delete()
deleteSelectedTracks()
}
}
}
@ -283,9 +283,9 @@ open class TrackCollectionFragment(
return true
} else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare(
this@TrackCollectionFragment, getSelectedTracks(),
refreshListView, cancellationToken!!,
navArgs.id
fragment = this@TrackCollectionFragment,
tracks = getSelectedTracks(),
additionalId = navArgs.id
)
return true
}
@ -294,46 +294,10 @@ open class TrackCollectionFragment(
}
override fun onDestroyView() {
cancellationToken!!.cancel()
rxBusSubscription.dispose()
super.onDestroyView()
}
private fun playNow(
insertionMode: MediaPlayerManager.InsertionMode,
selectedTracks: List<Track> = getSelectedTracks(),
toast: Boolean = false
) {
if (selectedTracks.isNotEmpty()) {
downloadHandler.addTracksToMediaController(
songs = selectedTracks,
insertionMode = insertionMode,
autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR),
playlistName = null,
fragment = this
)
} else {
playAll(false, insertionMode)
}
if (toast) {
val stringInt = when (insertionMode) {
MediaPlayerManager.InsertionMode.CLEAR ->
R.plurals.n_songs_added_play_now
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
R.plurals.n_songs_added_after_current
MediaPlayerManager.InsertionMode.APPEND ->
R.plurals.n_songs_added_to_end
}
val msg = resources.getQuantityString(
stringInt,
selectedTracks.size,
selectedTracks.size
)
Util.toast(requireContext(), msg)
}
}
/**
* Get the size of the underlying list
*/
@ -364,25 +328,65 @@ open class TrackCollectionFragment(
// Need a valid id to recurse sub directories stuff
if (hasSubFolders && navArgs.id != null) {
downloadHandler.fetchTracksAndAddToController(
mediaPlayerManager.playTracksAndToast(
fragment = this,
id = navArgs.id!!,
insertionMode = insertionMode,
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
id = navArgs.id!!,
shuffle = shuffle,
isArtist = isArtist
)
} else {
downloadHandler.addTracksToMediaController(
mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName
mediaPlayerManager.addToPlaylist(
songs = getAllSongs(),
insertionMode = insertionMode,
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
shuffle = shuffle,
playlistName = navArgs.playlistName,
fragment = this
shuffle = shuffle
)
if (insertionMode == MediaPlayerManager.InsertionMode.CLEAR) {
navigateToCurrent()
}
}
}
private fun unpinSelectedTracks() {
DownloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
tracks = getSelectedTracks()
)
}
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
)
}
private fun playSelectedOrAllTracks(
insertionMode: MediaPlayerManager.InsertionMode
) {
var tracks = getSelectedTracks()
if (tracks.isEmpty()) tracks = getAllSongs()
mediaPlayerManager.playTracksAndToast(
fragment = this,
insertionMode = insertionMode,
tracks = tracks
)
}
private fun deleteSelectedTracks() {
DownloadUtil.justDownload(
action = DownloadAction.DELETE,
fragment = this,
tracks = getSelectedTracks()
)
}
@Suppress("UNCHECKED_CAST")
private fun getAllSongs(): List<Track> {
@ -403,7 +407,7 @@ open class TrackCollectionFragment(
// Display toast: N tracks selected
val toastResId = R.string.select_album_n_selected
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
toast(getString(toastResId, selectedCount.coerceAtLeast(0)))
}
@Synchronized
@ -431,37 +435,6 @@ open class TrackCollectionFragment(
}
}
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedTracks()) {
var songs = tracks
if (songs.isEmpty()) {
songs = getAllSongs()
}
val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD
downloadHandler.justDownload(
action = action,
fragment = this,
tracks = songs
)
}
internal fun delete(songs: List<Track> = getSelectedTracks()) {
downloadHandler.justDownload(
action = DownloadAction.DELETE,
fragment = this,
tracks = songs
)
}
internal fun unpin(songs: List<Track> = getSelectedTracks()) {
downloadHandler.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
tracks = songs
)
}
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
Timber.i("Received list")
@ -606,48 +579,19 @@ open class TrackCollectionFragment(
private fun displayRandom() = (sortOrder == SortOrder.RANDOM) || navArgs.getRandom
@Suppress("LongMethod")
override fun onContextMenuItemSelected(
menuItem: MenuItem,
item: MusicDirectory.Child
): Boolean {
val songs = getClickedSong(item)
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true)
}
R.id.song_menu_play_next -> {
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true)
}
R.id.song_menu_play_last -> {
playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true)
}
R.id.song_menu_pin -> {
downloadBackground(true, songs)
}
R.id.song_menu_unpin -> {
unpin(songs)
}
R.id.song_menu_download -> {
downloadBackground(false, songs)
}
R.id.song_menu_share -> {
if (item is Track) {
shareHandler.createShare(
this,
tracks = listOf(item),
swipe = refreshListView,
cancellationToken = cancellationToken!!,
additionalId = navArgs.id
)
}
}
else -> {
return super.onContextItemSelected(menuItem)
}
}
return true
val tracks = getClickedSong(item)
return ContextMenuUtil.handleContextMenuTracks(
menuItem = menuItem,
tracks = tracks,
mediaPlayerManager = mediaPlayerManager,
fragment = this
)
}
private fun getClickedSong(item: MusicDirectory.Child): List<Track> {

View File

@ -25,11 +25,11 @@ import android.widget.CheckBox
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
import org.koin.core.component.KoinComponent
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
@ -39,12 +39,12 @@ import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.BackgroundTask
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.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
@ -56,14 +56,12 @@ import org.moire.ultrasonic.util.Util.toast
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class PlaylistsFragment : Fragment(), KoinComponent {
class PlaylistsFragment : ScopeFragment(), KoinScopeComponent {
private var refreshPlaylistsListView: SwipeRefreshLayout? = null
private var playlistsListView: ListView? = null
private var emptyTextView: View? = null
private var playlistAdapter: ArrayAdapter<Playlist>? = null
private val downloadHandler by inject<DownloadHandler>()
private var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
@ -115,7 +113,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
override fun doInBackground(): List<Playlist> {
val musicService = getMusicService()
val playlists = musicService.getPlaylists(refresh)
if (!isOffline()) CacheCleaner().cleanPlaylists(playlists)
val cacheCleaner: CacheCleaner by inject()
if (!isOffline()) cacheCleaner.cleanPlaylists(playlists)
return playlists
}
@ -147,8 +146,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
when (menuItem.itemId) {
R.id.playlist_menu_pin -> {
downloadHandler.justDownload(
DownloadAction.PIN,
DownloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = this,
id = playlist.id,
name = playlist.name,
@ -157,8 +156,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
)
}
R.id.playlist_menu_unpin -> {
downloadHandler.justDownload(
DownloadAction.UNPIN,
DownloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
id = playlist.id,
name = playlist.name,
@ -167,8 +166,8 @@ class PlaylistsFragment : Fragment(), KoinComponent {
)
}
R.id.playlist_menu_download -> {
downloadHandler.justDownload(
DownloadAction.DOWNLOAD,
DownloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = this,
id = playlist.id,
name = playlist.name,
@ -227,7 +226,6 @@ class PlaylistsFragment : Fragment(), KoinComponent {
playlistAdapter!!.remove(playlist)
playlistAdapter!!.notifyDataSetChanged()
toast(
context,
resources.getString(R.string.menu_deleted_playlist, playlist.name)
)
}
@ -246,7 +244,7 @@ class PlaylistsFragment : Fragment(), KoinComponent {
),
getErrorMessage(error)
)
toast(context, msg, false)
toast(msg, false)
}
}.execute()
}.setNegativeButton(R.string.common_cancel, null).show()
@ -310,7 +308,6 @@ class PlaylistsFragment : Fragment(), KoinComponent {
override fun done(result: Any?) {
load(true)
toast(
context,
resources.getString(R.string.playlist_updated_info, playlist.name)
)
}
@ -329,7 +326,7 @@ class PlaylistsFragment : Fragment(), KoinComponent {
),
getErrorMessage(error)
)
toast(context, msg, false)
toast(msg, false)
}
}.execute()
}

View File

@ -24,11 +24,11 @@ import android.widget.CheckBox
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
import org.koin.core.component.KoinComponent
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
@ -38,14 +38,15 @@ import org.moire.ultrasonic.fragment.FragmentTitle
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.BackgroundTask
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.DownloadAction
import org.moire.ultrasonic.util.DownloadUtil
import org.moire.ultrasonic.util.FragmentBackgroundTask
import org.moire.ultrasonic.util.LoadingTask
import org.moire.ultrasonic.util.TimeSpanPicker
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.toast
import org.moire.ultrasonic.view.ShareAdapter
/**
@ -53,12 +54,12 @@ import org.moire.ultrasonic.view.ShareAdapter
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class SharesFragment : Fragment(), KoinComponent {
class SharesFragment : ScopeFragment(), KoinScopeComponent {
private var refreshSharesListView: SwipeRefreshLayout? = null
private var sharesListView: ListView? = null
private var emptyTextView: View? = null
private var shareAdapter: ShareAdapter? = null
private val downloadHandler: DownloadHandler by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
@ -133,8 +134,8 @@ class SharesFragment : Fragment(), KoinComponent {
val share = sharesListView!!.getItemAtPosition(info.position) as Share
when (menuItem.itemId) {
R.id.share_menu_pin -> {
downloadHandler.justDownload(
DownloadAction.PIN,
DownloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = this,
id = share.id,
name = share.name,
@ -143,8 +144,8 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_unpin -> {
downloadHandler.justDownload(
DownloadAction.UNPIN,
DownloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
id = share.id,
name = share.name,
@ -153,8 +154,8 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_download -> {
downloadHandler.justDownload(
DownloadAction.DOWNLOAD,
DownloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = this,
id = share.id,
name = share.name,
@ -163,22 +164,20 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_play_now -> {
downloadHandler.fetchTracksAndAddToController(
mediaPlayerManager.playTracksAndToast(
this,
share.id,
share.name,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
id = share.id,
name = share.name,
shuffle = false
)
}
R.id.share_menu_play_shuffled -> {
downloadHandler.fetchTracksAndAddToController(
mediaPlayerManager.playTracksAndToast(
this,
share.id,
share.name,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
id = share.id,
name = share.name,
shuffle = true,
)
}
@ -214,8 +213,7 @@ class SharesFragment : Fragment(), KoinComponent {
override fun done(result: Any?) {
shareAdapter!!.remove(share)
shareAdapter!!.notifyDataSetChanged()
Util.toast(
context,
toast(
resources.getString(R.string.menu_deleted_share, share.name)
)
}
@ -237,7 +235,7 @@ class SharesFragment : Fragment(), KoinComponent {
getErrorMessage(error)
)
}
Util.toast(context, msg, false)
toast(msg, false)
}
}.execute()
}.setNegativeButton(R.string.common_cancel, null).show()
@ -315,8 +313,7 @@ class SharesFragment : Fragment(), KoinComponent {
override fun done(result: Any?) {
load(true)
Util.toast(
context,
toast(
resources.getString(R.string.playlist_updated_info, share.name)
)
}
@ -338,7 +335,7 @@ class SharesFragment : Fragment(), KoinComponent {
getErrorMessage(error)
)
}
Util.toast(context, msg, false)
toast(msg, false)
}
}.execute()
}

View File

@ -190,17 +190,17 @@ class ImageLoader(
if (artist.coverArt == null) return
val key = FileUtil.getArtistArtKey(artist.name, false)
val file = FileUtil.getAlbumArtFile(key)
cacheCoverArt(artist.coverArt!!, file)
downloadCoverArt(artist.coverArt!!, file)
}
/**
* Download a cover art file of a Track and cache it on disk
*/
fun cacheCoverArt(track: Track) {
cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
fun downloadCoverArt(track: Track) {
downloadCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
}
fun cacheCoverArt(id: String, file: String) = launch {
fun downloadCoverArt(id: String, file: String) = launch {
if (id.isBlank()) return@launch
withContext(Dispatchers.IO) {

View File

@ -56,8 +56,10 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent {
val albumArtFile = FileUtil.getAlbumArtFile(parts[1])
Timber.d("AlbumArtContentProvider openFile id: %s; file: %s", parts[0], albumArtFile)
// TODO: Check if the dependency on the image loader could be removed.
imageLoaderProvider.executeOn {
it.cacheCoverArt(parts[0], albumArtFile)
it.downloadCoverArt(parts[0], albumArtFile)
}
val file = File(albumArtFile)
if (!file.exists()) return null

View File

@ -23,7 +23,7 @@ import android.widget.RemoteViews
import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
import org.moire.ultrasonic.receiver.UltrasonicIntentReceiver
import org.moire.ultrasonic.util.Constants
import timber.log.Timber
@ -233,7 +233,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
// Emulate media button clicks.
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
intent.putExtra(
Intent.EXTRA_KEY_EVENT,
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
@ -241,12 +241,12 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// needed starting Android 12 (S = 31)
flags = flags or PendingIntent.FLAG_IMMUTABLE
flags = PendingIntent.FLAG_IMMUTABLE
}
pendingIntent = PendingIntent.getBroadcast(context, 11, intent, flags)
views.setOnClickPendingIntent(R.id.control_play, pendingIntent)
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
intent.putExtra(
Intent.EXTRA_KEY_EVENT,
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
@ -254,7 +254,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
pendingIntent = PendingIntent.getBroadcast(context, 12, intent, flags)
views.setOnClickPendingIntent(R.id.control_next, pendingIntent)
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java)
intent.putExtra(
Intent.EXTRA_KEY_EVENT,
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)

View File

@ -1,60 +0,0 @@
/*
* MediaButtonIntentReceiver.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Parcelable
import java.lang.Exception
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
/**
* This class is used to receive commands from the widget
*/
class MediaButtonIntentReceiver : BroadcastReceiver(), KoinComponent {
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
override fun onReceive(context: Context, intent: Intent) {
val intentAction = intent.action
// If media button are turned off and we received a media button, exit
if (!Settings.mediaButtonsEnabled && Intent.ACTION_MEDIA_BUTTON == intentAction) return
// Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets
if (Intent.ACTION_MEDIA_BUTTON != intentAction &&
Constants.CMD_PROCESS_KEYCODE != intentAction
) return
val extras = intent.extras ?: return
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(Intent.EXTRA_KEY_EVENT, Parcelable::class.java)
} else {
@Suppress("DEPRECATION")
extras.get(Intent.EXTRA_KEY_EVENT) as Parcelable?
}
Timber.i("Got MEDIA_BUTTON key event: %s", event)
try {
val serviceIntent = Intent(Constants.CMD_PROCESS_KEYCODE)
serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event)
lifecycleSupport.receiveIntent(serviceIntent)
if (isOrderedBroadcast) {
abortBroadcast()
}
} catch (ignored: Exception) {
// Ignored.
}
}
}

View File

@ -10,20 +10,19 @@ package org.moire.ultrasonic.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.koin.java.KoinJavaComponent.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import timber.log.Timber
class UltrasonicIntentReceiver : BroadcastReceiver() {
private val lifecycleSupport = inject<MediaPlayerLifecycleSupport>(
MediaPlayerLifecycleSupport::class.java
)
class UltrasonicIntentReceiver : BroadcastReceiver(), KoinComponent {
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
override fun onReceive(context: Context, intent: Intent) {
val intentAction = intent.action
Timber.i("Received Ultrasonic Intent: %s", intentAction)
try {
lifecycleSupport.value.receiveIntent(intent)
lifecycleSupport.receiveIntent(intent)
if (isOrderedBroadcast) {
abortBroadcast()
}

View File

@ -315,6 +315,10 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
return musicService.getStreamUrl(id, maxBitRate, format)
}
override fun isJukeboxAvailable(): Boolean {
return musicService.isJukeboxAvailable()
}
@Throws(Exception::class)
override fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus {
return musicService.updateJukeboxPlaylist(ids)

View File

@ -29,8 +29,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.Track
@ -61,6 +63,7 @@ private const val CHECK_INTERVAL = 5000L
class DownloadService : Service(), KoinComponent {
private var scope: CoroutineScope? = null
private val storageMonitor: ExternalStorageMonitor by inject()
private val cacheCleaner: CacheCleaner by inject()
private val binder: IBinder = SimpleServiceBinder(this)
private var isInForeground = false
@ -153,7 +156,7 @@ class DownloadService : Service(), KoinComponent {
// Stop Executor service when done downloading
if (activeDownloads.isEmpty()) {
CacheCleaner().cleanSpace()
cacheCleaner.cleanSpace()
stopSelf()
}
@ -279,7 +282,17 @@ class DownloadService : Service(), KoinComponent {
updateSaveFlag: Boolean = false
) {
CoroutineScope(Dispatchers.IO).launch {
downloadAsync(tracks, save, isHighPriority, updateSaveFlag)
}
}
suspend fun downloadAsync(
tracks: List<Track>,
save: Boolean = false,
isHighPriority: Boolean = false,
updateSaveFlag: Boolean = false
) {
withContext(Dispatchers.IO) {
// Remove tracks which are already downloaded and update the save flag
// if needed
var filteredTracks = if (updateSaveFlag) {
@ -384,8 +397,14 @@ class DownloadService : Service(), KoinComponent {
failedList.clear()
}
fun delete(track: Track) {
private fun delete(track: Track) {
CoroutineScope(Dispatchers.IO).launch {
deleteAsync(track)
}
}
private suspend fun deleteAsync(track: Track) {
withContext(Dispatchers.IO) {
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
failedList[track.id]?.let { downloadQueue.remove(it) }
cancelDownload(track)
@ -394,7 +413,8 @@ class DownloadService : Service(), KoinComponent {
Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile())
postState(track, DownloadState.IDLE)
CacheCleaner().cleanDatabaseSelective(track)
val cacheCleaner: CacheCleaner by inject(CacheCleaner::class.java)
cacheCleaner.cleanDatabaseSelective(track)
Util.scanMedia(track.getPinnedFile())
}
}
@ -409,22 +429,38 @@ class DownloadService : Service(), KoinComponent {
tracks.forEach(::delete)
}
fun unpin(track: Track) {
// Update Pinned flag of items in progress
downloadQueue.get(track.id)?.pinned = false
activeDownloads[track.id]?.downloadTrack?.pinned = false
failedList[track.id]?.pinned = false
suspend fun unpinAsync(tracks: List<Track>) {
tracks.forEach { unpinAsync(it) }
}
val pinnedFile = track.getPinnedFile()
if (!Storage.isPathExists(pinnedFile)) return
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
try {
Storage.rename(file, track.getCompleteFile())
} catch (ignored: FileAlreadyExistsException) {
// Play console has revealed a crash when for some reason both files exist
Storage.delete(file.path)
suspend fun deleteAsync(tracks: List<Track>) {
tracks.forEach { deleteAsync(it) }
}
private fun unpin(track: Track) {
CoroutineScope(Dispatchers.IO).launch {
unpinAsync(track)
}
}
private suspend fun unpinAsync(track: Track) {
withContext(Dispatchers.IO) {
// Update Pinned flag of items in progress
downloadQueue.get(track.id)?.pinned = false
activeDownloads[track.id]?.downloadTrack?.pinned = false
failedList[track.id]?.pinned = false
val pinnedFile = track.getPinnedFile()
if (!Storage.isPathExists(pinnedFile)) return@withContext
val file = Storage.getFromPath(track.getPinnedFile()) ?: return@withContext
try {
Storage.rename(file, track.getCompleteFile())
} catch (ignored: FileAlreadyExistsException) {
// Play console has revealed a crash when for some reason both files exist
Storage.delete(file.path)
}
postState(track, DownloadState.DONE)
}
postState(track, DownloadState.DONE)
}
@Suppress("ReturnCount")

View File

@ -256,7 +256,7 @@ class DownloadTask(
// Download the largest size that we can display in the UI
imageLoaderProvider.executeOn { imageLoader ->
imageLoader.cacheCoverArt(this)
imageLoader.downloadCoverArt(this)
// Cache small copies of the Artist picture
directArtist?.let { imageLoader.cacheArtistPicture(it) }
compilationArtist?.let { imageLoader.cacheArtistPicture(it) }

View File

@ -1,13 +1,12 @@
/*
* AutoMediaBrowserCallback.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* MediaLibrarySessionCallback.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
package org.moire.ultrasonic.service
import android.content.Context
import android.os.Build
import android.os.Bundle
import androidx.car.app.connection.CarConnection
@ -32,11 +31,14 @@ import androidx.media3.session.SessionResult.RESULT_SUCCESS
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
@ -48,8 +50,6 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.buildMediaItem
import org.moire.ultrasonic.util.toMediaItem
@ -98,10 +98,12 @@ const val PLAY_COMMAND = "play "
* MediaBrowserService implementation for e.g. Android Auto
*/
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
class MediaLibrarySessionCallback :
MediaLibraryService.MediaLibrarySession.Callback,
KoinComponent {
private val applicationContext: Context by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val playbackStateSerializer: PlaybackStateSerializer by inject()
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@ -243,6 +245,25 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
val result = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
serviceScope.launch {
val state = playbackStateSerializer.deserializeNow()
if (state != null) {
result.set(state.toMediaItemsWithStartPosition())
withContext(Dispatchers.Main) {
mediaSession.player.shuffleModeEnabled = state.shufflePlay
mediaSession.player.repeatMode = state.repeatMode
}
}
}
return result
}
private fun configureRepeatMode(player: Player) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Timber.d("Car app library available, observing CarConnection")
@ -251,7 +272,7 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac
var lastCarConnectionType = -1
CarConnection(applicationContext).type.observeForever {
CarConnection(UApp.applicationContext()).type.observeForever {
if (lastCarConnectionType == it)
return@observeForever

View File

@ -16,8 +16,6 @@ import android.os.Build
import android.view.KeyEvent
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
@ -29,11 +27,13 @@ import timber.log.Timber
/**
* This class is responsible for handling received events for the Media Player implementation
*/
class MediaPlayerLifecycleSupport : KoinComponent {
class MediaPlayerLifecycleSupport(
val mediaPlayerManager: MediaPlayerManager,
private val playbackStateSerializer: PlaybackStateSerializer,
val imageLoaderProvider: ImageLoaderProvider,
private val cacheCleaner: CacheCleaner
) : KoinComponent {
private lateinit var ratingManager: RatingManager
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var created = false
private var headsetEventReceiver: BroadcastReceiver? = null
@ -70,7 +70,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
registerHeadsetReceiver()
CacheCleaner().clean()
cacheCleaner.clean()
created = true
ratingManager = RatingManager.instance
Timber.i("LifecycleSupport created")
@ -78,15 +78,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
private fun restoreLastSession(autoPlay: Boolean, afterRestore: Runnable?) {
playbackStateSerializer.deserialize {
if (it == null) return@deserialize null
Timber.i("Restoring %s songs", it.songs.size)
Timber.i("Restoring %s songs", it!!.songs.size)
mediaPlayerManager.restore(
it,
autoPlay,
false
)
mediaPlayerManager.restore(it, autoPlay)
afterRestore?.run()
}
}
@ -99,7 +94,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
applicationContext().unregisterReceiver(headsetEventReceiver)
imageLoaderProvider.clearImageLoader()
UApp.instance!!.shutdownKoin()
created = false
Timber.i("LifecycleSupport destroyed")

View File

@ -7,10 +7,11 @@
package org.moire.ultrasonic.service
import android.content.ComponentName
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.IntRange
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
@ -29,17 +30,20 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.PlaybackService
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.DownloadUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.navigateToCurrent
import org.moire.ultrasonic.util.Util.toast
import org.moire.ultrasonic.util.launchWithToast
import org.moire.ultrasonic.util.toMediaItem
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber
@ -56,12 +60,9 @@ private const val VOLUME_DELTA = 0.05f
@Suppress("TooManyFunctions")
class MediaPlayerManager(
private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor,
val context: Context
private val externalStorageMonitor: ExternalStorageMonitor
) : KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private var created = false
var suggestedPlaylistName: String? = null
var keepScreenOn = false
@ -73,8 +74,10 @@ class MediaPlayerManager(
private var mainScope = CoroutineScope(Dispatchers.Main)
private var sessionToken =
SessionToken(context, ComponentName(context, PlaybackService::class.java))
private var sessionToken = SessionToken(
UApp.applicationContext(),
ComponentName(UApp.applicationContext(), PlaybackService::class.java)
)
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
@ -145,12 +148,11 @@ class MediaPlayerManager(
Timber.w(error.toString())
if (!isJukeboxEnabled) return
val context = UApp.applicationContext()
mainScope.launch {
Util.toast(
context,
toast(
error.errorCode,
false
false,
UApp.applicationContext()
)
}
isJukeboxEnabled = false
@ -199,7 +201,7 @@ class MediaPlayerManager(
}
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
val jukebox = activeServerProvider.getActiveServer().jukeboxByDefault
val jukebox = it.jukeboxByDefault
// Remove all songs when changing servers before turning on Jukebox.
// Jukebox wouldn't find the songs on the new server.
if (jukebox) controller?.clearMediaItems()
@ -246,10 +248,10 @@ class MediaPlayerManager(
private fun createMediaController(onCreated: () -> Unit) {
mediaControllerFuture = MediaController.Builder(
context,
UApp.applicationContext(),
sessionToken
)
// Specify mainThread explicitely
// Specify mainThread explicitly
.setApplicationLooper(Looper.getMainLooper())
.buildAsync()
@ -320,17 +322,15 @@ class MediaPlayerManager(
externalStorageMonitor.onDestroy()
DownloadService.requestStop()
created = false
Timber.i("MediaPlayerController destroyed")
Timber.i("MediaPlayerManager destroyed")
}
@Synchronized
fun restore(
state: PlaybackState,
autoPlay: Boolean,
newPlaylist: Boolean
autoPlay: Boolean
) {
val insertionMode = if (newPlaylist) InsertionMode.CLEAR
else InsertionMode.APPEND
val insertionMode = InsertionMode.APPEND
addToPlaylist(
state.songs,
@ -474,6 +474,80 @@ class MediaPlayerManager(
}
}
private suspend fun addToPlaylistAsync(
songs: List<Track>,
autoPlay: Boolean,
shuffle: Boolean,
insertionMode: InsertionMode
) {
withContext(Dispatchers.Main) {
addToPlaylist(
songs = songs,
autoPlay = autoPlay,
shuffle = shuffle,
insertionMode = insertionMode
)
}
}
@Suppress("LongParameterList")
fun playTracksAndToast(
fragment: Fragment,
insertionMode: InsertionMode,
tracks: List<Track> = listOf(),
id: String? = null,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
shuffle: Boolean = false,
isArtist: Boolean = false
) {
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
scope.launchWithToast {
val list: List<Track> =
tracks.ifEmpty {
requireNotNull(id)
DownloadUtil.getTracksFromServerAsync(isArtist, id, isDirectory, name, isShare)
}
addToPlaylistAsync(
songs = list,
insertionMode = insertionMode,
autoPlay = (insertionMode == InsertionMode.CLEAR),
shuffle = shuffle,
)
if (insertionMode == InsertionMode.CLEAR) {
fragment.navigateToCurrent()
}
when (insertionMode) {
InsertionMode.AFTER_CURRENT ->
quantize(R.plurals.n_songs_added_after_current, list)
InsertionMode.APPEND ->
quantize(R.plurals.n_songs_added_to_end, list)
InsertionMode.CLEAR -> {
if (Settings.shouldTransitionOnPlayback)
null
else
quantize(R.plurals.n_songs_added_play_now, list)
}
}
}
}
private fun quantize(resId: Int, tracks: List<Track>): String {
return UApp.applicationContext().resources.getQuantityString(
resId,
tracks.size,
tracks.size
)
}
@set:Synchronized
var isShufflePlayEnabled: Boolean
get() = controller?.shuffleModeEnabled == true
@ -649,21 +723,6 @@ class MediaPlayerManager(
Timber.i("MediaPlayerController released")
}
/**
* This function calls the music service directly and
* therefore can't be called from the main thread
*/
val isJukeboxAvailable: Boolean
get() {
try {
val username = activeServerProvider.getActiveServer().userName
return getMusicService().getUser(username).jukeboxRole
} catch (all: Exception) {
Timber.w(all, "Error getting user information")
}
return false
}
fun adjustVolume(up: Boolean) {
val delta = if (up) VOLUME_DELTA else -VOLUME_DELTA
var gain = controller?.volume ?: return
@ -676,7 +735,7 @@ class MediaPlayerManager(
/*
* Sets the rating of the current track
*/
fun setRating(rating: Rating) {
private fun setRating(rating: Rating) {
if (controller is MediaController) {
(controller as MediaController).setRating(rating)
}
@ -724,7 +783,7 @@ class MediaPlayerManager(
val currentMediaItemIndex: Int
get() = controller?.currentMediaItemIndex ?: -1
fun getCurrentShuffleIndex(): Int {
private fun getCurrentShuffleIndex(): Int {
val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1
return getShuffledIndexOf(currentMediaItemIndex)
}
@ -768,9 +827,8 @@ class MediaPlayerManager(
* in the shuffled timeline.
* @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found.
*/
fun getShuffledIndexOf(searchPosition: Int): Int {
return getWindowIndexWhere(false) {
_, windowIndex ->
private fun getShuffledIndexOf(searchPosition: Int): Int {
return getWindowIndexWhere(false) { _, windowIndex ->
windowIndex == searchPosition
}
}
@ -784,8 +842,7 @@ class MediaPlayerManager(
* @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found.
*/
fun getUnshuffledIndexOf(shufflePosition: Int): Int {
return getWindowIndexWhere(true) {
count, _ ->
return getWindowIndexWhere(true) { count, _ ->
count == shufflePosition
}
}

View File

@ -138,6 +138,8 @@ interface MusicService {
@Throws(Exception::class)
fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String?
fun isJukeboxAvailable(): Boolean
@Throws(Exception::class)
fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus

View File

@ -335,6 +335,10 @@ class OfflineMusicService : MusicService, KoinComponent {
}
}
override fun isJukeboxAvailable(): Boolean {
return false
}
@Throws(Exception::class)
override fun updateJukeboxPlaylist(ids: List<String>?): JukeboxStatus {
throw OfflineException("Jukebox not available in offline mode")

View File

@ -1,10 +1,10 @@
/*
* PlaybackService.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.app.PendingIntent
@ -42,15 +42,11 @@ import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.CachedDataSource
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.JukeboxMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
@ -68,7 +64,7 @@ class PlaybackService :
private var equalizer: EqualizerController? = null
private val activeServerProvider: ActiveServerProvider by inject()
private lateinit var librarySessionCallback: AutoMediaBrowserCallback
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
private var rxBusSubscription = CompositeDisposable()
@ -115,6 +111,7 @@ class PlaybackService :
isStarted = false
stopForegroundRemoveNotification()
stopSelf()
instance = null
}
private val resolver: ResolvingDataSource.Resolver = ResolvingDataSource.Resolver {
@ -142,7 +139,7 @@ class PlaybackService :
actualBackend = desiredBackend
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback()
librarySessionCallback = MediaLibrarySessionCallback()
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)

View File

@ -1,7 +1,9 @@
package org.moire.ultrasonic.service
import androidx.media3.session.MediaSession
import java.io.Serializable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.toMediaItem
/**
* Represents the state of the Media Player implementation
@ -17,3 +19,12 @@ data class PlaybackState(
private const val serialVersionUID = -293487987L
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun PlaybackState.toMediaItemsWithStartPosition(): MediaSession.MediaItemsWithStartPosition {
return MediaSession.MediaItemsWithStartPosition(
songs.map { it.toMediaItem() },
currentPlayingIndex,
currentPlayingPosition.toLong(),
)
}

View File

@ -24,6 +24,8 @@ import timber.log.Timber
* This class is responsible for the serialization / deserialization
* of the playlist and the player state (e.g. current playing number and play position)
* to the filesystem.
*
* TODO: Should use: MediaItemsWithStartPosition
*/
class PlaybackStateSerializer : KoinComponent {
@ -56,7 +58,7 @@ class PlaybackStateSerializer : KoinComponent {
}
}
fun serializeNow(
private fun serializeNow(
tracks: Iterable<Track>,
currentPlayingIndex: Int,
currentPlayingPosition: Int,
@ -85,7 +87,10 @@ class PlaybackStateSerializer : KoinComponent {
if (isDeserializing.get()) return
ioScope.launch {
try {
deserializeNow(afterDeserialized)
val state = deserializeNow()
mainScope.launch {
afterDeserialized(state)
}
isSetup.set(true)
} catch (all: Exception) {
Timber.e(all, "Had a problem deserializing:")
@ -95,11 +100,11 @@ class PlaybackStateSerializer : KoinComponent {
}
}
private fun deserializeNow(afterDeserialized: (PlaybackState?) -> Unit?) {
fun deserializeNow(): PlaybackState? {
val state = FileUtil.deserialize<PlaybackState>(
context, Constants.FILENAME_PLAYLIST_SER
) ?: return
) ?: return null
Timber.i(
"Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d, shuffle: %b",
@ -108,9 +113,7 @@ class PlaybackStateSerializer : KoinComponent {
state.shufflePlay
)
mainScope.launch {
afterDeserialized(state)
}
return state
}
companion object {

View File

@ -51,7 +51,7 @@ import timber.log.Timber
*/
@Suppress("LargeClass")
open class RESTMusicService(
val subsonicAPIClient: SubsonicAPIClient,
private val subsonicAPIClient: SubsonicAPIClient,
private val activeServerProvider: ActiveServerProvider
) : MusicService {
@ -504,6 +504,11 @@ open class RESTMusicService(
builder.build()
}
override fun isJukeboxAvailable(): Boolean {
val username = activeServerProvider.getActiveServer().userName
return getUser(username).jukeboxRole
}
@Throws(Exception::class)
override fun updateJukeboxPlaylist(
ids: List<String>?

View File

@ -8,6 +8,7 @@ import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.util.concurrent.TimeUnit
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.domain.Track
class RxBus {
@ -32,9 +33,9 @@ class RxBus {
var activeServerChangingObservable: Observable<Int> =
activeServerChangingPublisher
var activeServerChangedPublisher: PublishSubject<Int> =
var activeServerChangedPublisher: PublishSubject<ServerSetting> =
PublishSubject.create()
var activeServerChangedObservable: Observable<Int> =
var activeServerChangedObservable: Observable<ServerSetting> =
activeServerChangedPublisher.observeOn(mainThread())
val themeChangedEventPublisher: PublishSubject<Unit> =

View File

@ -1,260 +0,0 @@
/*
* DownloadHandler.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.subsonic
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import java.util.LinkedList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.executeTaskWithToast
/**
* Retrieves a list of songs and adds them to the now playing list
*/
@Suppress("LongParameterList")
class DownloadHandler(
val mediaPlayerManager: MediaPlayerManager,
private val networkAndStorageChecker: NetworkAndStorageChecker
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val maxSongs = 500
fun justDownload(
action: DownloadAction,
fragment: Fragment,
id: String? = null,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
isArtist: Boolean = false,
tracks: List<Track>? = null
) {
var successString: String? = null
// Launch the Job
executeTaskWithToast({
val tracksToDownload: List<Track> = tracks
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
withContext(Dispatchers.Main) {
// If we are just downloading tracks we don't need to add them to the controller
when (action) {
DownloadAction.DOWNLOAD -> DownloadService.download(
tracksToDownload,
save = false,
updateSaveFlag = true
)
DownloadAction.PIN -> DownloadService.download(
tracksToDownload,
save = true,
updateSaveFlag = true
)
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
}
successString = when (action) {
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
R.plurals.n_songs_to_be_downloaded,
tracksToDownload.size,
tracksToDownload.size
)
DownloadAction.UNPIN -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_unpinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.PIN -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_pinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.DELETE -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_deleted,
tracksToDownload.size,
tracksToDownload.size
)
}
}
}
}) { successString }
}
fun fetchTracksAndAddToController(
fragment: Fragment,
id: String,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
insertionMode: MediaPlayerManager.InsertionMode,
autoPlay: Boolean,
shuffle: Boolean = false,
isArtist: Boolean = false
) {
var successString: String? = null
// Launch the Job
executeTaskWithToast({
val songs: MutableList<Track> =
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
withContext(Dispatchers.Main) {
addTracksToMediaController(
songs = songs,
insertionMode = insertionMode,
autoPlay = autoPlay,
shuffle = shuffle,
playlistName = null,
fragment = fragment
)
// Play Now doesn't get a Toast :)
successString = when (insertionMode) {
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
fragment.resources.getQuantityString(
R.plurals.n_songs_added_after_current,
songs.size,
songs.size
)
MediaPlayerManager.InsertionMode.APPEND ->
fragment.resources.getQuantityString(
R.plurals.n_songs_added_to_end,
songs.size,
songs.size
)
else -> null
}
}
}) { successString }
}
fun addTracksToMediaController(
songs: List<Track>,
insertionMode: MediaPlayerManager.InsertionMode,
autoPlay: Boolean,
shuffle: Boolean = false,
playlistName: String? = null,
fragment: Fragment
) {
if (songs.isEmpty()) return
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
if (playlistName != null) {
mediaPlayerManager.suggestedPlaylistName = playlistName
}
mediaPlayerManager.addToPlaylist(
songs,
autoPlay,
shuffle,
insertionMode
)
if (Settings.shouldTransitionOnPlayback &&
insertionMode == MediaPlayerManager.InsertionMode.CLEAR
) {
fragment.findNavController().popBackStack(R.id.playerFragment, true)
fragment.findNavController().navigate(R.id.playerFragment)
}
}
private fun getTracksFromServer(
isArtist: Boolean,
id: String,
isDirectory: Boolean,
name: String?,
isShare: Boolean
): MutableList<Track> {
val musicService = getMusicService()
val songs: MutableList<Track> = LinkedList()
val root: MusicDirectory
if (shouldUseId3Tags() && isArtist) {
return getSongsForArtist(id)
} else {
if (isDirectory) {
root = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id, name, false)
else
musicService.getMusicDirectory(id, name, false)
} else if (isShare) {
root = MusicDirectory()
val shares = musicService.getShares(true)
// Filter the received shares by the given id, and get their entries
val entries = shares.filter { it.id == id }.flatMap { it.getEntries() }
root.addAll(entries)
} else {
root = musicService.getPlaylist(id, name!!)
}
getSongsRecursively(root, songs)
}
return songs
}
@Suppress("DestructuringDeclarationWithTooManyEntries")
@Throws(Exception::class)
private fun getSongsRecursively(
parent: MusicDirectory,
songs: MutableList<Track>
) {
if (songs.size > maxSongs) {
return
}
for (song in parent.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}
}
val musicService = getMusicService()
for ((id1, _, _, title) in parent.getAlbums()) {
val root: MusicDirectory = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id1, title, false)
else
musicService.getMusicDirectory(id1, title, false)
getSongsRecursively(root, songs)
}
}
@Throws(Exception::class)
private fun getSongsForArtist(
id: String
): MutableList<Track> {
val songs: MutableList<Track> = LinkedList()
val musicService = getMusicService()
val artist = musicService.getAlbumsOfArtist(id, "", false)
for ((id1) in artist) {
val albumDirectory = musicService.getAlbumAsDir(
id1,
"",
false
)
for (song in albumDirectory.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}
}
}
return songs
}
}
enum class DownloadAction {
DOWNLOAD, PIN, UNPIN, DELETE
}

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.subsonic
import android.content.Context
import androidx.core.content.res.ResourcesCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -20,8 +19,7 @@ import timber.log.Timber
/**
* Handles the lifetime of the Image Loader
*/
class
ImageLoaderProvider(val context: Context) :
class ImageLoaderProvider :
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.IO) {
private var imageLoader: ImageLoader? = null

View File

@ -1,19 +1,19 @@
package org.moire.ultrasonic.subsonic
import android.content.Context
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.util.Util
/**
* Utility class for checking the availability of the network and storage
*/
class NetworkAndStorageChecker(val context: Context) {
class NetworkAndStorageChecker {
fun warnIfNetworkOrStorageUnavailable() {
if (!Util.isExternalStoragePresent()) {
Util.toast(context, R.string.select_album_no_sdcard)
Util.toast(R.string.select_album_no_sdcard, true, UApp.applicationContext())
} else if (!isOffline() && !Util.hasUsableNetwork()) {
Util.toast(context, R.string.select_album_no_network)
Util.toast(R.string.select_album_no_network, true, UApp.applicationContext())
}
}
}

View File

@ -8,7 +8,6 @@
package org.moire.ultrasonic.subsonic
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
@ -17,27 +16,27 @@ import android.widget.EditText
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.lifecycle.lifecycleScope
import java.util.Locale
import java.util.regex.Pattern
import kotlin.collections.ArrayList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.BackgroundTask
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.FragmentBackgroundTask
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShareDetails
import org.moire.ultrasonic.util.TimeSpanPicker
import org.moire.ultrasonic.util.Util.getString
import org.moire.ultrasonic.util.Util.ifNotNull
/**
* This class handles sharing items in the media library
*/
class ShareHandler(val context: Context) {
class ShareHandler {
private var shareDescription: EditText? = null
private var timeSpanPicker: TimeSpanPicker? = null
private var shareOnServerCheckBox: CheckBox? = null
@ -51,21 +50,26 @@ class ShareHandler(val context: Context) {
fun share(
fragment: Fragment,
shareDetails: ShareDetails,
swipe: SwipeRefreshLayout?,
cancellationToken: CancellationToken,
additionalId: String?
) {
val task: BackgroundTask<Share?> = object : FragmentBackgroundTask<Share?>(
fragment.requireActivity(),
true,
swipe,
cancellationToken
) {
@Throws(Throwable::class)
override fun doInBackground(): Share? {
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
scope.launch {
val share = createShareOnServer(shareDetails, additionalId)
startActivityForShare(share, shareDetails, fragment)
}
}
private suspend fun createShareOnServer(
shareDetails: ShareDetails,
additionalId: String?
): Share? {
return withContext(Dispatchers.IO) {
return@withContext try {
val ids: MutableList<String> = ArrayList()
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null
if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1)
return@withContext null
if (shareDetails.Entries.isEmpty()) {
additionalId.ifNotNull {
ids.add(it)
@ -86,78 +90,80 @@ class ShareHandler(val context: Context) {
val shares =
musicService.createShare(ids, shareDetails.Description, timeInMillis)
return shares[0]
}
override fun done(result: Share?) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
if (result != null) {
// Created a share, send the URL
intent.putExtra(
Intent.EXTRA_TEXT,
String.format(
Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url
)
)
} else {
// Sending only text details
val textBuilder = StringBuilder()
textBuilder.appendLine(Settings.shareGreeting)
if (!shareDetails.Entries[0].title.isNullOrEmpty())
textBuilder.append(context.resources.getString(R.string.common_title))
.append(": ").appendLine(shareDetails.Entries[0].title)
if (!shareDetails.Entries[0].artist.isNullOrEmpty())
textBuilder.append(context.resources.getString(R.string.common_artist))
.append(": ").appendLine(shareDetails.Entries[0].artist)
if (!shareDetails.Entries[0].album.isNullOrEmpty())
textBuilder.append(context.resources.getString(R.string.common_album))
.append(": ").append(shareDetails.Entries[0].album)
intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString())
}
fragment.activity?.startActivity(
Intent.createChooser(
intent,
context.resources.getString(R.string.share_via)
)
)
// Return the share
shares[0]
} catch (ignored: Exception) {
null
}
}
task.execute()
}
private suspend fun startActivityForShare(
result: Share?,
shareDetails: ShareDetails,
fragment: Fragment
) {
return withContext(Dispatchers.Main) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
if (result != null) {
// Created a share, send the URL
intent.putExtra(
Intent.EXTRA_TEXT,
String.format(
Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url
)
)
} else {
// Sending only text details
val textBuilder = StringBuilder()
textBuilder.appendLine(Settings.shareGreeting)
if (!shareDetails.Entries[0].title.isNullOrEmpty())
textBuilder.append(getString(R.string.common_title))
.append(": ").appendLine(shareDetails.Entries[0].title)
if (!shareDetails.Entries[0].artist.isNullOrEmpty())
textBuilder.append(getString(R.string.common_artist))
.append(": ").appendLine(shareDetails.Entries[0].artist)
if (!shareDetails.Entries[0].album.isNullOrEmpty())
textBuilder.append(getString(R.string.common_album))
.append(": ").append(shareDetails.Entries[0].album)
intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString())
}
fragment.activity?.startActivity(
Intent.createChooser(
intent,
getString(R.string.share_via)
)
)
}
}
fun createShare(
fragment: Fragment,
tracks: List<Track?>?,
swipe: SwipeRefreshLayout?,
cancellationToken: CancellationToken,
additionalId: String? = null
) {
val askForDetails = Settings.shouldAskForShareDetails
val shareDetails = ShareDetails()
shareDetails.Entries = tracks
if (askForDetails) {
showDialog(fragment, shareDetails, swipe, cancellationToken, additionalId)
showDialog(fragment, shareDetails, additionalId)
} else {
shareDetails.Description = Settings.defaultShareDescription
shareDetails.Expiration = System.currentTimeMillis() +
Settings.defaultShareExpirationInMillis
share(fragment, shareDetails, swipe, cancellationToken, additionalId)
share(fragment, shareDetails, additionalId)
}
}
@Suppress("LongMethod")
@SuppressLint("InflateParams")
private fun showDialog(
fragment: Fragment,
shareDetails: ShareDetails,
swipe: SwipeRefreshLayout?,
cancellationToken: CancellationToken,
additionalId: String?
) {
val layout = LayoutInflater.from(fragment.context).inflate(R.layout.share_details, null)
@ -175,18 +181,57 @@ class ShareHandler(val context: Context) {
textViewExpiration = layout.findViewById<View>(R.id.textViewExpiration) as TextView
}
// Handle the visibility based on shareDetails.Entries size
if (shareDetails.Entries.size == 1) {
// For single songs the sharing may be done by text only
shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ ->
updateVisibility()
}
shareOnServerCheckBox?.isChecked = Settings.shareOnServer
} else {
shareOnServerCheckBox?.isVisible = false
}
updateVisibility()
// Set up the dialog builder
val builder = makeDialogBuilder(fragment, shareDetails, additionalId, layout)
// Initialize UI components with default values
setupDefaultValues()
builder.create()
builder.show()
}
private fun setupDefaultValues() {
val defaultDescription = Settings.defaultShareDescription
val timeSpan = Settings.defaultShareExpiration
val split = pattern.split(timeSpan)
if (split.size == 2) {
val timeSpanAmount = split[0].toInt()
val timeSpanType = split[1]
if (timeSpanAmount > 0) {
noExpirationCheckBox!!.isChecked = false
timeSpanPicker!!.isEnabled = true
timeSpanPicker!!.setTimeSpanAmount(timeSpanAmount.toString())
timeSpanPicker!!.timeSpanType = timeSpanType
} else {
noExpirationCheckBox!!.isChecked = true
timeSpanPicker!!.isEnabled = false
}
} else {
noExpirationCheckBox!!.isChecked = true
timeSpanPicker!!.isEnabled = false
}
shareDescription!!.setText(defaultDescription)
}
private fun makeDialogBuilder(
fragment: Fragment,
shareDetails: ShareDetails,
additionalId: String?,
layout: View?
): ConfirmationDialog.Builder {
val builder = ConfirmationDialog.Builder(fragment.requireContext())
builder.setTitle(R.string.share_set_share_options)
@ -214,7 +259,7 @@ class ShareHandler(val context: Context) {
Settings.shareOnServer = shareDetails.ShareOnServer
}
share(fragment, shareDetails, swipe, cancellationToken, additionalId)
share(fragment, shareDetails, additionalId)
}
builder.setNegativeButton(R.string.common_cancel) { dialog, _ ->
@ -224,35 +269,12 @@ class ShareHandler(val context: Context) {
builder.setView(layout)
builder.setCancelable(true)
timeSpanPicker!!.setTimeSpanDisableText(context.resources.getString(R.string.no_expiration))
// Set up the timeSpanPicker
timeSpanPicker!!.setTimeSpanDisableText(getString(R.string.no_expiration))
noExpirationCheckBox!!.setOnCheckedChangeListener { _, b ->
timeSpanPicker!!.isEnabled = !b
}
val defaultDescription = Settings.defaultShareDescription
val timeSpan = Settings.defaultShareExpiration
val split = pattern.split(timeSpan)
if (split.size == 2) {
val timeSpanAmount = split[0].toInt()
val timeSpanType = split[1]
if (timeSpanAmount > 0) {
noExpirationCheckBox!!.isChecked = false
timeSpanPicker!!.isEnabled = true
timeSpanPicker!!.setTimeSpanAmount(timeSpanAmount.toString())
timeSpanPicker!!.timeSpanType = timeSpanType
} else {
noExpirationCheckBox!!.isChecked = true
timeSpanPicker!!.isEnabled = false
}
} else {
noExpirationCheckBox!!.isChecked = true
timeSpanPicker!!.isEnabled = false
}
shareDescription!!.setText(defaultDescription)
builder.create()
builder.show()
return builder
}
private fun updateVisibility() {

View File

@ -16,7 +16,7 @@ class VideoPlayer {
companion object {
fun playVideo(context: Context, track: Track?) {
if (!Util.hasUsableNetwork() || track == null) {
Util.toast(context, R.string.select_album_no_network)
Util.toast(R.string.select_album_no_network, true, context)
return
}
try {
@ -32,7 +32,7 @@ class VideoPlayer {
)
context.startActivity(intent)
} catch (all: Exception) {
Util.toast(context, all.toString(), false)
Util.toast(all.toString(), false, context)
}
}
}

View File

@ -11,7 +11,6 @@ import android.system.Os
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -19,7 +18,7 @@ import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
@ -38,7 +37,6 @@ import timber.log.Timber
*/
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinComponent {
private var mainScope = CoroutineScope(Dispatchers.Main)
private val activeServerProvider by inject<ActiveServerProvider>()
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
@ -235,16 +233,14 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaPlayerManager: MediaPlayerManager by inject()
val playlist = mainScope.future { mediaPlayerManager.playlist }.get()
for (item in playlist) {
val track = item.toTrack()
// We just take the last published playlist from RX
val playlist = RxBus.playlistObservable.blockingLast()
for (track in playlist) {
filesToNotDelete.add(track.getPartialFile())
filesToNotDelete.add(track.getCompleteFile())
filesToNotDelete.add(track.getPinnedFile())
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}

View File

@ -37,4 +37,5 @@ object Constants {
const val FILENAME_PLAYLIST_SER = "downloadstate.ser"
const val ALBUM_ART_FILE = "folder.jpeg"
const val RESULT_CLOSE_ALL = 1337
const val MAX_SONGS_RECURSIVE = 500
}

View File

@ -0,0 +1,141 @@
/*
* ContextMenuUtil.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.view.MenuItem
import androidx.fragment.app.Fragment
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.subsonic.ShareHandler
object ContextMenuUtil : KoinComponent {
/*
* Callback for menu items of collections (albums, artists etc)
*/
fun handleContextMenu(
menuItem: MenuItem,
item: Identifiable,
isArtist: Boolean,
mediaPlayerManager: MediaPlayerManager,
fragment: Fragment
): Boolean {
when (menuItem.itemId) {
R.id.menu_play_now ->
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
id = item.id,
isArtist = isArtist
)
R.id.menu_play_next ->
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
id = item.id,
isArtist = isArtist
)
R.id.menu_play_last ->
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
id = item.id,
isArtist = isArtist
)
R.id.menu_pin ->
DownloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = fragment,
id = item.id,
isArtist = isArtist
)
R.id.menu_unpin ->
DownloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = fragment,
id = item.id,
isArtist = isArtist
)
R.id.menu_download ->
DownloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = fragment,
id = item.id,
isArtist = isArtist
)
else -> return false
}
return true
}
fun handleContextMenuTracks(
menuItem: MenuItem,
tracks: List<Track>,
mediaPlayerManager: MediaPlayerManager,
fragment: Fragment
): Boolean {
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
tracks = tracks
)
}
R.id.song_menu_play_next -> {
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
tracks = tracks
)
}
R.id.song_menu_play_last -> {
mediaPlayerManager.playTracksAndToast(
fragment = fragment,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
tracks = tracks
)
}
R.id.song_menu_pin -> {
DownloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = fragment,
tracks = tracks
)
}
R.id.song_menu_unpin -> {
DownloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = fragment,
tracks = tracks
)
}
R.id.song_menu_download -> {
DownloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = fragment,
tracks = tracks
)
}
R.id.song_menu_share -> {
val shareHandler: ShareHandler by inject()
shareHandler.createShare(
fragment = fragment,
tracks = tracks,
additionalId = null
)
}
else -> return false
}
return true
}
}

View File

@ -9,14 +9,12 @@ package org.moire.ultrasonic.util
import android.os.Handler
import android.os.Looper
import androidx.fragment.app.Fragment
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import timber.log.Timber
@ -30,53 +28,50 @@ object CoroutinePatterns {
}
}
fun CoroutineScope.executeTaskWithToast(
task: suspend CoroutineScope.() -> Unit,
successString: () -> String?
): Job {
fun CoroutineScope.launchWithToast(
block: suspend CoroutineScope.() -> String?
) {
// Launch the Job
val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task)
val deferred = async(CoroutinePatterns.loggingExceptionHandler, block = block)
// Setup a handler when the job is done
job.invokeOnCompletion {
deferred.invokeOnCompletion {
val toastString = if (it != null && it !is CancellationException) {
CommunicationError.getErrorMessage(it)
} else {
successString()
null
}
// Return early if nothing to post
if (toastString == null) return@invokeOnCompletion
launch(Dispatchers.Main) {
Util.toast(UApp.applicationContext(), toastString)
}
}
return job
}
fun CoroutineScope.executeTaskWithModalDialog(
fragment: Fragment,
task: suspend CoroutineScope.() -> Unit,
successString: () -> String
) {
// Create the job
val job = executeTaskWithToast(task, successString)
// Create the dialog
val builder = InfoDialog.Builder(fragment.requireContext())
builder.setTitle(R.string.background_task_wait)
builder.setMessage(R.string.background_task_loading)
builder.setOnCancelListener { job.cancel() }
builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
val dialog = builder.create()
dialog.show()
// Add additional handler to close the dialog
job.invokeOnCompletion {
launch(Dispatchers.Main) {
dialog.dismiss()
val successString = toastString ?: deferred.await()
if (successString != null) {
Util.toast(successString, UApp.applicationContext())
}
}
}
}
// Unused, kept commented for eventual later use
// fun CoroutineScope.executeTaskWithModalDialog(
// fragment: Fragment,
// task: suspend CoroutineScope.() -> String?
// ) {
// // Create the job
// val job = launchWithToast(task)
//
// // Create the dialog
// val builder = InfoDialog.Builder(fragment.requireContext())
// builder.setTitle(R.string.background_task_wait)
// builder.setMessage(R.string.background_task_loading)
// builder.setOnCancelListener { job.cancel() }
// builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
// val dialog = builder.create()
// dialog.show()
//
// // Add additional handler to close the dialog
// job.invokeOnCompletion {
// launch(Dispatchers.Main) {
// dialog.dismiss()
// }
// }
// }

View File

@ -0,0 +1,198 @@
/*
* DownloadUtil.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import java.util.LinkedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MusicServiceFactory
/**
* Retrieves a list of songs and adds them to the now playing list
*/
@Suppress("LongParameterList")
object DownloadUtil {
fun justDownload(
action: DownloadAction,
fragment: Fragment,
id: String? = null,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
isArtist: Boolean = false,
tracks: List<Track>? = null
) {
val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope
// Launch the Job
scope.launchWithToast {
val tracksToDownload: List<Track> = tracks
?: getTracksFromServerAsync(isArtist, id!!, isDirectory, name, isShare)
// If we are just downloading tracks we don't need to add them to the controller
when (action) {
DownloadAction.DOWNLOAD -> DownloadService.downloadAsync(
tracksToDownload,
save = false,
updateSaveFlag = true
)
DownloadAction.PIN -> DownloadService.downloadAsync(
tracksToDownload,
save = true,
updateSaveFlag = true
)
DownloadAction.UNPIN -> DownloadService.unpinAsync(tracksToDownload)
DownloadAction.DELETE -> DownloadService.deleteAsync(tracksToDownload)
}
// Return the string which should be displayed
getToastString(action, fragment, tracksToDownload)
}
}
suspend fun getTracksFromServerAsync(
isArtist: Boolean,
id: String,
isDirectory: Boolean,
name: String?,
isShare: Boolean
): MutableList<Track> {
return withContext(Dispatchers.IO) {
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
}
}
fun getTracksFromServer(
isArtist: Boolean,
id: String,
isDirectory: Boolean,
name: String?,
isShare: Boolean
): MutableList<Track> {
val musicService = MusicServiceFactory.getMusicService()
val songs: MutableList<Track> = LinkedList()
val root: MusicDirectory
if (ActiveServerProvider.shouldUseId3Tags() && isArtist) {
return getSongsForArtist(id)
} else {
if (isDirectory) {
root = if (ActiveServerProvider.shouldUseId3Tags())
musicService.getAlbumAsDir(id, name, false)
else
musicService.getMusicDirectory(id, name, false)
} else if (isShare) {
root = MusicDirectory()
val shares = musicService.getShares(true)
// Filter the received shares by the given id, and get their entries
val entries = shares.filter { it.id == id }.flatMap { it.getEntries() }
root.addAll(entries)
} else {
root = musicService.getPlaylist(id, name!!)
}
getSongsRecursively(root, songs)
}
return songs
}
@Suppress("DestructuringDeclarationWithTooManyEntries")
@Throws(Exception::class)
private fun getSongsRecursively(
parent: MusicDirectory,
songs: MutableList<Track>
) {
if (songs.size > Constants.MAX_SONGS_RECURSIVE) {
return
}
for (song in parent.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}
}
val musicService = MusicServiceFactory.getMusicService()
for ((id1, _, _, title) in parent.getAlbums()) {
val root: MusicDirectory = if (ActiveServerProvider.shouldUseId3Tags())
musicService.getAlbumAsDir(id1, title, false)
else
musicService.getMusicDirectory(id1, title, false)
getSongsRecursively(root, songs)
}
}
@Throws(Exception::class)
private fun getSongsForArtist(
id: String
): MutableList<Track> {
val songs: MutableList<Track> = LinkedList()
val musicService = MusicServiceFactory.getMusicService()
val artist = musicService.getAlbumsOfArtist(id, "", false)
for ((id1) in artist) {
val albumDirectory = musicService.getAlbumAsDir(
id1,
"",
false
)
for (song in albumDirectory.getTracks()) {
if (!song.isVideo) {
songs.add(song)
}
}
}
return songs
}
private fun getToastString(
action: DownloadAction,
fragment: Fragment,
tracksToDownload: List<Track>
): String {
return when (action) {
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
R.plurals.n_songs_to_be_downloaded,
tracksToDownload.size,
tracksToDownload.size
)
DownloadAction.UNPIN -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_unpinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.PIN -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_pinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.DELETE -> {
fragment.resources.getQuantityString(
R.plurals.n_songs_deleted,
tracksToDownload.size,
tracksToDownload.size
)
}
}
}
}
enum class DownloadAction {
DOWNLOAD, PIN, UNPIN, DELETE
}

View File

@ -136,10 +136,6 @@ object Settings {
val seekIntervalMillis: Long
get() = (seekInterval / 1000).toLong()
@JvmStatic
var mediaButtonsEnabled
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)
var resumePlayOnHeadphonePlug
by BooleanSetting(R.string.setting_key_resume_play_on_headphones_plug, true)

View File

@ -41,7 +41,7 @@ object Storage {
if (rootNotFoundError) {
Settings.customCacheLocation = false
Settings.cacheLocationUri = ""
Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error)
Util.toast(R.string.settings_cache_location_error, true, UApp.applicationContext())
}
}

View File

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

View File

@ -8,7 +8,6 @@
package org.moire.ultrasonic.util
import android.Manifest.permission.POST_NOTIFICATIONS
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
@ -37,12 +36,15 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.AnyRes
import androidx.annotation.StringRes
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.navigation.fragment.findNavController
import java.io.Closeable
import java.io.UnsupportedEncodingException
import java.security.MessageDigest
@ -107,33 +109,41 @@ object Util {
context.getString(R.string.setting_key_theme_dark) -> {
R.style.UltrasonicTheme_Dark
}
context.getString(R.string.setting_key_theme_black) -> {
R.style.UltrasonicTheme_Black
}
context.getString(R.string.setting_key_theme_light) -> {
R.style.UltrasonicTheme_Light
}
else -> {
R.style.UltrasonicTheme_DayNight
}
}
}
fun getString(@StringRes resId: Int): String {
return applicationContext().resources.getString(resId)
}
@JvmStatic
@JvmOverloads
fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) {
toast(context, context!!.getString(messageId), shortDuration)
fun toast(messageId: Int, shortDuration: Boolean = true, context: Context?) {
toast(applicationContext().getString(messageId), shortDuration, context)
}
@JvmStatic
fun toast(context: Context?, message: CharSequence?) {
toast(context, message, true)
fun toast(message: CharSequence, context: Context?) {
toast(message, true, context)
}
@JvmStatic
@SuppressLint("ShowToast") // Invalid warning
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
// If called after doing some background processing, our context might have expired!
// Toast needs a real context or it will throw a IllegalAccessException
// We wrap it in a try-catch block, because if called after doing
// some background processing, our context might have expired!
fun toast(message: CharSequence, shortDuration: Boolean, context: Context?) {
try {
if (toast == null) {
toast = Toast.makeText(
@ -153,6 +163,22 @@ object Util {
}
}
fun Fragment.toast(message: CharSequence, shortDuration: Boolean = true) {
toast(
message,
shortDuration = shortDuration,
context = this.context
)
}
fun Fragment.toast(messageId: Int = 0, shortDuration: Boolean = true) {
toast(
messageId = messageId,
shortDuration = shortDuration,
context = this.context
)
}
/**
* Converts a byte-count to a formatted string suitable for display to the user.
* For instance:
@ -479,7 +505,7 @@ object Util {
@JvmStatic
fun isNullOrWhiteSpace(string: String?): Boolean {
return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty()
return string.isNullOrEmpty() || string.trim { it <= ' ' }.isEmpty()
}
@JvmOverloads
@ -504,18 +530,22 @@ object Util {
seconds
)
}
hours > 0 -> {
String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
}
minutes >= DEGRADE_PRECISION_AFTER -> {
String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}
minutes > 0 -> String.format(
Locale.getDefault(),
"%d:%02d",
minutes,
seconds
)
else -> String.format(Locale.getDefault(), "0:%02d", seconds)
}
}
@ -557,7 +587,7 @@ object Util {
val requestPermissionLauncher =
fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) {
toast(applicationContext(), R.string.notification_permission_required)
toast(R.string.notification_permission_required, context = fragment)
}
}
@ -569,6 +599,7 @@ object Util {
}
}
}
fun postNotificationIfPermitted(
notificationManagerCompat: NotificationManagerCompat,
id: Int,
@ -583,8 +614,8 @@ object Util {
notificationManagerCompat.notify(id, notification)
}
}
@JvmStatic
@Suppress("DEPRECATION")
fun getVersionName(context: Context): String? {
var versionName: String? = null
val pm = context.packageManager
@ -838,4 +869,11 @@ object Util {
Timber.d("${it.key}: ${it.value}")
}
}
fun Fragment.navigateToCurrent() {
if (Settings.shouldTransitionOnPlayback) {
findNavController().popBackStack(R.id.playerFragment, true)
findNavController().navigate(R.id.playerFragment)
}
}
}

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Načítám&#8230;</string>
<string name="background_task.network_error">Chyba sítě. Ověřte adresu serveru nebo zkuste později.</string>
<string name="background_task.unsupported_api">API serveru v%1$s nepodporuje tuto funkci.</string>
<string name="background_task.no_network">Tento program vyžaduje síťové připojení. Zapněte Wi-Fi nebo mobilní připojení.</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Nesrozumitelná odpověď. Ověřte adresu serveru.</string>
<string name="background_task.ssl_cert_error">Chyba HTTPS certifikátu: %1$s.</string>
<string name="background_task.ssl_error">Vyjímka SSL připojení. Ověřte certifikát serveru.</string>
<string name="background_task.wait">Chvilku strpení&#8230;</string>
<string name="button_bar.bookmarks">Záložky</string>
<string name="button_bar.browse">Knihovna médií</string>
<string name="button_bar.chat">Chat</string>
@ -188,8 +186,6 @@
<string name="settings.max_bitrate_unlimited">Neomezené</string>
<string name="settings.max_bitrate_wifi">Max Bitrate - wi-fi</string>
<string name="settings.max_songs">Maximum skladeb</string>
<string name="settings.media_button_summary">Odpovídat na tlačítka ovládání médií telefonu, sluchátek a bluetooth</string>
<string name="settings.media_button_title">Tlačítka médií</string>
<string name="settings.network_timeout">Čas vypršení připojení</string>
<string name="settings.network_timeout_105000">105 sekund</string>
<string name="settings.network_timeout_120000">120 sekund</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Lade…</string>
<string name="background_task.network_error">Ein Netzwerkfehler ist aufgetreten. Bitte die Serveradresse prüfen oder später noch einmal versuchen.</string>
<string name="background_task.unsupported_api">Server API v%1$s unterstützt diese Funktion nicht.</string>
<string name="background_task.no_network">Dieses Programm benötigt eine Netzwerkverbindung. Bitte das WLAN oder Mobilfunk einschalten.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">Antwort nicht verstanden. Bitte die Serveradresse überprüfen.</string>
<string name="background_task.ssl_cert_error">HTTPS Zertifikatsfehler: %1$s.</string>
<string name="background_task.ssl_error">SSL Verbindungsfehler. Bitte das Serverzertifikat überprüfen.</string>
<string name="background_task.wait">Bitte warten…</string>
<string name="button_bar.bookmarks">Lesezeichen</string>
<string name="button_bar.browse">Medienbibliothek</string>
<string name="button_bar.chat">Chat</string>
@ -230,8 +228,6 @@
<string name="settings.max_bitrate_unlimited">Unbegrenzt</string>
<string name="settings.max_bitrate_wifi">Max. Bitrate - WLAN</string>
<string name="settings.max_songs">Max. Anzahl der Titel</string>
<string name="settings.media_button_summary">Auf Telefon, Headset und Bluetooth-Media-Tasten reagieren</string>
<string name="settings.media_button_title">Medien Tasten</string>
<string name="settings.network_timeout">Netzwerk Zeitüberschreitung</string>
<string name="settings.network_timeout_105000">105 Sekunden</string>
<string name="settings.network_timeout_120000">120 Sekunden</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Cargando…</string>
<string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string>
<string name="background_task.unsupported_api">La API del servidor v%1$s no admite esta función.</string>
<string name="background_task.no_network">Este programa requiere acceso a la red. Por favor enciende la Wi-Fi o la red móvil.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">No se entiende la respuesta. Por favor comprueba la dirección del servidor.</string>
<string name="background_task.ssl_cert_error">Error del certificado HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Excepción de conexión SSL. Compruebe el certificado del servidor.</string>
<string name="background_task.wait">Por favor espera…</string>
<string name="button_bar.bookmarks">Marcadores</string>
<string name="button_bar.browse">Biblioteca</string>
<string name="button_bar.chat">Chat</string>
@ -232,8 +230,6 @@
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
<string name="settings.max_bitrate_wifi">Bitrate máximo - Wi-Fi</string>
<string name="settings.max_songs">Máximo de Canciones</string>
<string name="settings.media_button_summary">Responder a los botones multimedia del dispositivo, auriculares y Bluetooth</string>
<string name="settings.media_button_title">Botones multimedia</string>
<string name="settings.network_timeout">Tiempo de espera de la red</string>
<string name="settings.network_timeout_105000">105 segundos</string>
<string name="settings.network_timeout_120000">120 segundos</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Chargement…</string>
<string name="background_task.network_error">Une erreur réseau est survenue. Veuillez vérifier l\'adresse du serveur ou réessayer plus tard.</string>
<string name="background_task.unsupported_api">LAPI v%1$s du serveur ne supporte pas cette fonction.</string>
<string name="background_task.no_network">Cette application requiert un accès au réseau. Veuillez activer le Wi-Fi ou le réseau mobile.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">Réponse incorrecte. Veuillez vérifier l\'adresse du serveur.</string>
<string name="background_task.ssl_cert_error">Erreur de certificat HTTPS : %1$s.</string>
<string name="background_task.ssl_error">Erreur de connexion SSL. Veuillez vérifier le certificat du serveur.</string>
<string name="background_task.wait">Veuillez patienter…</string>
<string name="button_bar.bookmarks">Signets</string>
<string name="button_bar.browse">Bibliothèque musicale</string>
<string name="button_bar.chat">Salon de discussion</string>
@ -225,8 +223,6 @@
<string name="settings.max_bitrate_unlimited">Illimité</string>
<string name="settings.max_bitrate_wifi">Débit maximal - Wi-Fi</string>
<string name="settings.max_songs">Titres maximum</string>
<string name="settings.media_button_summary">Répondre au boutons média de l\'appareil, du casque et du Bluetooth</string>
<string name="settings.media_button_title">Boutons média</string>
<string name="settings.network_timeout">Délai d\'attente de connexion</string>
<string name="settings.network_timeout_105000">105 secondes</string>
<string name="settings.network_timeout_120000">120 secondes</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="background_task.loading">Cargando…</string>
<string name="background_task.network_error">Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde.</string>
<string name="background_task.unsupported_api">A API do servidor v%1$s non admite esta función.</string>
<string name="background_task.no_network">Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">Non se entende a resposta. Por favor comproba a dirección do servidor.</string>
<string name="background_task.ssl_cert_error">Erro do certificado HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Excepción de conexión SSL. Comprobe o certificado do servidor.</string>
<string name="background_task.wait">Por favor agarde…</string>
<string name="button_bar.browse">Biblioteca</string>
<string name="button_bar.chat">Chat</string>
<string name="button_bar.now_playing">Reproducindo agora</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Betöltés&#8230;</string>
<string name="background_task.network_error">Hálózati hiba történt! Kérjük, ellenőrizze a kiszolgáló címét vagy próbálja később!</string>
<string name="background_task.unsupported_api">A v%1$s verziójú Szerver api nem támogatja ezt a funkciót.</string>
<string name="background_task.no_network">Az alkalmazás hálózati hozzáférést igényel. Kérjük, kapcsolja be a Wi-Fi-t vagy a mobilhálózatot!</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Értelmezhetetlen válasz! Kérjük, ellenőrizze a kiszolgáló címét!</string>
<string name="background_task.ssl_cert_error">HTTPS tanúsítványhiba: %1$s.</string>
<string name="background_task.ssl_error">SSL kapcsolat kivétel. Kérjük, ellenőrizze a szerver tanúsítványát.</string>
<string name="background_task.wait">Kérem várjon!&#8230;</string>
<string name="button_bar.bookmarks">Könyvjelzők</string>
<string name="button_bar.browse">Médiakönyvtár</string>
<string name="button_bar.chat">Csevegés (Chat)</string>
@ -194,8 +192,6 @@
<string name="settings.max_bitrate_unlimited">Korlátlan</string>
<string name="settings.max_bitrate_wifi">Max. bitráta - Wi-Fi kapcsolat</string>
<string name="settings.max_songs">Dalok max. találati száma</string>
<string name="settings.media_button_summary">Telefon irányítása a Bluetooth eszköz, vagy a fülhallgató vezérlőgombjaival.</string>
<string name="settings.media_button_title">Média vezérlőgombok</string>
<string name="settings.network_timeout">Hálózati időtúllépés</string>
<string name="settings.network_timeout_105000">105 másodperc</string>
<string name="settings.network_timeout_120000">120 másodperc</string>

View File

@ -1,13 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Caricamento…</string>
<string name="background_task.network_error">Si è verificato un errore di rete. Si prega di controllare l\'indirizzo del server o riprovare più tardi.</string>
<string name="background_task.no_network">Questo programma richiede l\'accesso alla rete. Per favore, abilita la connessione Wi-FI o la rete mobile.</string>
<string name="background_task.not_found">Risorsa non trovata. Si prega di controllare l\'indirizzo del server.</string>
<string name="background_task.parse_error">Risposta non comprensibile. Si prega di controllare l\'indirizzo del server.</string>
<string name="background_task.ssl_cert_error">Errore certificato HTTPS. %1$s.</string>
<string name="background_task.ssl_error">Anomalia connessione SSL. Si prega di controllare il certificato del server.</string>
<string name="background_task.wait">Attendere per favore#8230;</string>
<string name="button_bar.bookmarks">Segnalibri</string>
<string name="button_bar.browse">Libreria multimediale</string>
<string name="button_bar.chat">Chat</string>
@ -184,8 +182,6 @@
<string name="settings.max_bitrate_unlimited">Illimitato</string>
<string name="settings.max_bitrate_wifi">Bitrate Max - Wi-Fi</string>
<string name="settings.max_songs">N° Max Canzoni</string>
<string name="settings.media_button_summary">Controlli per risposta alle chiamate, per cuffie e Bluetooth</string>
<string name="settings.media_button_title">Tasti Media</string>
<string name="settings.network_timeout">Timeout Rete</string>
<string name="settings.network_timeout_105000">105 seconds</string>
<string name="settings.network_timeout_120000">120 seconds</string>

View File

@ -1,11 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">読み込み中…</string>
<string name="background_task.network_error">ネットワークエラーが発生しました。サーバーのアドレスを確認してやり直してください。</string>
<string name="background_task.parse_error">応答が確認できません。サーバーのアドレスを確認してください。</string>
<string name="background_task.ssl_cert_error">HTTPS証明書エラー: %1$s.</string>
<string name="background_task.ssl_error">SSL接続が異常です。サーバーの証明書を確認してください。</string>
<string name="background_task.wait">お待ち下さい…</string>
<string name="button_bar.bookmarks">ブックマーク</string>
<string name="button_bar.browse">メディアライブラリ</string>
<string name="button_bar.chat">チャット</string>
@ -181,8 +179,6 @@
<string name="settings.max_bitrate_64">64 Kbps</string>
<string name="settings.max_bitrate_80">80 Kbps</string>
<string name="settings.max_bitrate_96">96 Kbps</string>
<string name="settings.media_button_summary">端末本体、ヘッドセットやBluetoothの再生コントロールボタンに対応します</string>
<string name="settings.media_button_title">メディアボタン</string>
<string name="settings.network_timeout_15000">15秒</string>
<string name="settings.network_timeout_75000">75秒</string>
<string name="settings.network_timeout_90000">90秒</string>

View File

@ -37,7 +37,6 @@
<string name="buttons.stop">Stopp</string>
<string name="button_bar.search">Søk</string>
<string name="chat.send_a_message">Send en melding</string>
<string name="background_task.loading">Laster inn …</string>
<string name="common.appname">Ultrasonic</string>
<string name="button_bar.browse">Mediebibliotek</string>
<string name="buttons.play">Spill</string>
@ -137,7 +136,6 @@
<string name="background_task.not_found">Fant ikke ressursen. Sjekk tjeneradressen.</string>
<string name="background_task.parse_error">Forsto ikke svaret. Sjekk tjeneradressen.</string>
<string name="background_task.ssl_cert_error">HTTPS-sertifikatsfeil: %1$s.</string>
<string name="background_task.wait">Vent …</string>
<string name="button_bar.bookmarks">Bokmerker</string>
<string name="button_bar.now_playing">Spilles nå</string>
<string name="podcasts_channels.empty">Ingen nettradioopptakskanaler registrert.</string>
@ -219,7 +217,6 @@
<string name="settings.invalid_url">Angi en gyldig nettadresse.</string>
<string name="settings.max_artists">Maks. artister</string>
<string name="settings.max_bitrate_unlimited">Ubegrenset</string>
<string name="settings.media_button_title">Medieknapper</string>
<string name="settings.network_timeout">Nettverkstidsavbrudd</string>
<string name="settings.network_timeout_90000">90 sekunder</string>
<string name="settings.network_timeout_75000">75 sekunder</string>
@ -317,7 +314,6 @@
<string name="menu.deleted_playlist_error">Klarte ikke å slette %s-spillelisten</string>
<string name="settings.download_transition_summary">Bytt til «Spilles nå» etter at avspilling startes i medievisning</string>
<string name="settings.hide_media_toast">Trer i effekt neste gang Android skanner enheten din for musikk.</string>
<string name="settings.media_button_summary">Svarer på enhets-, hodesett, og Blåtannsmedieknapper</string>
<string name="settings.wifi_required_summary">Kun last ned på ubegrensede tilkoblinger</string>
<string name="notification.downloading_title">Last ned medier i bakgrunnen …\?</string>
<string name="notification.permission_required">Merknader kreves for medieavspilling. Du kan innvilge tilgangen når som helst i Android-innstillingene.</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Bezig met laden&#8230;</string>
<string name="background_task.network_error">Er is een netwerkfout opgetreden. Controleer het serveradres of probeer het later opnieuw.</string>
<string name="background_task.unsupported_api">De server-api, v %1$s, heeft geen ondersteuning voor deze functie.</string>
<string name="background_task.no_network">Deze app vereist netwerktoegang. Schakel wifi of mobiel internet in.</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Het antwoord werd niet begrepen. Controleer het serveradres.</string>
<string name="background_task.ssl_cert_error">HTTPS-certificaatfout: %1$s.</string>
<string name="background_task.ssl_error">SSL-verbindingsuitzondering. Controleer het servercertificaat.</string>
<string name="background_task.wait">Even geduld&#8230;</string>
<string name="button_bar.bookmarks">Bladwijzers</string>
<string name="button_bar.browse">Mediabibliotheek</string>
<string name="button_bar.chat">Chat</string>
@ -233,8 +231,6 @@
<string name="settings.max_bitrate_unlimited">Ongelimiteerd</string>
<string name="settings.max_bitrate_wifi">Max. bitsnelheid via wifi</string>
<string name="settings.max_songs">Max. aantal nummers</string>
<string name="settings.media_button_summary">Reageren op telefoon-, headset- en bluetooth-mediatoetsen.</string>
<string name="settings.media_button_title">Mediatoetsen</string>
<string name="settings.network_timeout">Netwerktime-out</string>
<string name="settings.network_timeout_105000">105 seconden</string>
<string name="settings.network_timeout_120000">120 seconden</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Ładowanie…</string>
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera lub spróbować później.</string>
<string name="background_task.unsupported_api">API serwera w wersji v%1$s nie wspiera tej funkcjonalności.</string>
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć Wi-Fi lub dane komórkowe.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>
<string name="background_task.ssl_cert_error">Błąd certyfikatu HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera.</string>
<string name="background_task.wait">Proszę czekać…</string>
<string name="button_bar.bookmarks">Zakładki</string>
<string name="button_bar.browse">Biblioteka</string>
<string name="button_bar.chat">Czat</string>
@ -187,8 +185,6 @@
<string name="settings.max_bitrate_unlimited">Bez limitu</string>
<string name="settings.max_bitrate_wifi">Maksymalny bitrate dla połączenia Wi-Fi</string>
<string name="settings.max_songs">Maksymalna ilość wyników - utwory</string>
<string name="settings.media_button_summary">Reaguj na przyciski multimedialne telefonu, słuchawek i urządzeń Bluetooth</string>
<string name="settings.media_button_title">Przyciski</string>
<string name="settings.network_timeout">Przekroczenie limitu czasu sieci</string>
<string name="settings.network_timeout_105000">105 sekund</string>
<string name="settings.network_timeout_120000">120 sekund</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Carregando&#8230;</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
<string name="background_task.unsupported_api">O servidor api v%1$s não tem suporte para esta função.</string>
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>
<string name="background_task.ssl_cert_error">Erro de certificado HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Exceção de conexão SSL. Verifique o certificado do servidor.</string>
<string name="background_task.wait">Por favor aguarde&#8230;</string>
<string name="button_bar.bookmarks">Favoritos</string>
<string name="button_bar.browse">Biblioteca de Mídia</string>
<string name="button_bar.chat">Chat</string>
@ -231,8 +229,6 @@
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
<string name="settings.max_bitrate_wifi">Taxa Máxima de Bits - Wi-Fi</string>
<string name="settings.max_songs">Máximo de Músicas</string>
<string name="settings.media_button_summary">Obedecer aos botões do celular, fones e botões de mídia do Bluetooth</string>
<string name="settings.media_button_title">Botões de Mídia</string>
<string name="settings.network_timeout">Timeout da Rede</string>
<string name="settings.network_timeout_105000">105 segundos</string>
<string name="settings.network_timeout_120000">120 segundos</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Carregando…</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>
<string name="background_task.ssl_cert_error">Erro de certificado HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Exceção de conexão SSL. Verifique o certificado do servidor.</string>
<string name="background_task.wait">Por favor aguarde…</string>
<string name="button_bar.bookmarks">Favoritos</string>
<string name="button_bar.browse">Biblioteca de Mídia</string>
<string name="button_bar.chat">Chat</string>
@ -187,8 +185,6 @@
<string name="settings.max_bitrate_unlimited">Ilimitado</string>
<string name="settings.max_bitrate_wifi">Máx. de Taxa de Bits - Wi-Fi</string>
<string name="settings.max_songs">Máximo de Músicas</string>
<string name="settings.media_button_summary">Obedecer aos botões do telemóvel, auricular e botões de mídia do Bluetooth</string>
<string name="settings.media_button_title">Botões de Mídia</string>
<string name="settings.network_timeout">Timeout da Rede</string>
<string name="settings.network_timeout_105000">105 segundos</string>
<string name="settings.network_timeout_120000">120 segundos</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Загрузка&#8230;</string>
<string name="background_task.network_error">Произошла ошибка сети. Пожалуйста, проверьте адрес сервера или повторите попытку позже.</string>
<string name="background_task.unsupported_api">Серверный api версии %1$s не поддерживает эту функцию.</string>
<string name="background_task.no_network">Эта программа требует доступа к сети. Пожалуйста, включите Wi-Fi или мобильную сеть.</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Не понятный ответ. Пожалуйста, проверьте адрес сервера.</string>
<string name="background_task.ssl_cert_error">Ошибка сертификата HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Исключение SSL-соединения. Пожалуйста, проверьте сертификат сервера.</string>
<string name="background_task.wait">Пожалуйста, подождите&#8230;</string>
<string name="button_bar.bookmarks">Закладки</string>
<string name="button_bar.browse">Медиа библиотека</string>
<string name="button_bar.chat">Чат</string>
@ -212,8 +210,6 @@
<string name="settings.max_bitrate_unlimited">Неограниченный</string>
<string name="settings.max_bitrate_wifi">Максимальный битрейт - Wi-Fi подключение</string>
<string name="settings.max_songs">Максимум треков</string>
<string name="settings.media_button_summary">Отвечать на телефон, гарнитуру и мультимедийные кнопки Bluetooth</string>
<string name="settings.media_button_title">Медиа кнопки</string>
<string name="settings.network_timeout">Таймаут сети</string>
<string name="settings.network_timeout_105000">105 секунд</string>
<string name="settings.network_timeout_120000">120 секунд</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">加载中…</string>
<string name="background_task.network_error">发生网络错误。请检查服务器地址或稍后重试。</string>
<string name="background_task.unsupported_api">服务端 API v%1$s 不支持此功能。</string>
<string name="background_task.no_network">此软件需要连接网络,请打开 Wi-Fi 或移动网络。</string>
@ -8,7 +7,6 @@
<string name="background_task.parse_error">未知回复内容,请检查服务器地址。</string>
<string name="background_task.ssl_cert_error">HTTPS 证书错误:%1$s.</string>
<string name="background_task.ssl_error">SSL 连接异常。请检查服务器证书。</string>
<string name="background_task.wait">请稍等…</string>
<string name="button_bar.bookmarks">书签</string>
<string name="button_bar.browse">媒体库</string>
<string name="button_bar.chat">聊天</string>
@ -218,8 +216,6 @@
<string name="settings.max_bitrate_unlimited">不限制</string>
<string name="settings.max_bitrate_wifi">最大比特率 - WIFI</string>
<string name="settings.max_songs">最大歌曲</string>
<string name="settings.media_button_summary">响应手机、耳机和蓝牙设备的媒体按钮</string>
<string name="settings.media_button_title">媒体按钮</string>
<string name="settings.network_timeout">网络超时</string>
<string name="settings.network_timeout_105000">105 秒</string>
<string name="settings.network_timeout_120000">120 秒</string>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">載入中…</string>
<string name="button_bar.bookmarks">書籤</string>
<string name="button_bar.browse">媒體庫</string>
<string name="button_bar.now_playing">正在播放</string>
@ -146,7 +145,6 @@
<string name="language.nl">荷蘭語</string>
<string name="download.jukebox_off">已關閉遠端控制,音樂將在手機上播放。</string>
<string name="language.de">德語</string>
<string name="background_task.wait">請稍候…</string>
<string name="common.unpin">取消固定</string>
<string name="download.playlist_name">輸入播放清單名稱:</string>
<string name="main.albums_by_year">依照時間排列</string>

View File

@ -19,7 +19,6 @@
<string name="setting_key.preload_count" translatable="false">preloadCount</string>
<string name="setting_key.parallel_downloads" translatable="false">parallelDownloads</string>
<string name="setting_key.hide_media" translatable="false">hideMedia</string>
<string name="setting_key.media_buttons" translatable="false">mediaButtons</string>
<string name="setting_key.scrobble" translatable="false">scrobble</string>
<string name="setting_key.server_scaling" translatable="false">serverScaling</string>
<string name="setting_key.wifi_required_for_download" translatable="false">wifiRequiredForDownload</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Loading…</string>
<string name="background_task.network_error">A network error occurred. Please check the server address or try again later.</string>
<string name="background_task.unsupported_api">Server API v%1$s does not support this function.</string>
<string name="background_task.no_network">This program requires network access. Please turn on Wi-Fi or mobile network.</string>
@ -9,7 +8,6 @@
<string name="background_task.parse_error">Didn\'t understand the reply. Please check the server address.</string>
<string name="background_task.ssl_cert_error">HTTPS certificate error: %1$s.</string>
<string name="background_task.ssl_error">SSL connection exception. Please check server certificate.</string>
<string name="background_task.wait">Please wait…</string>
<string name="button_bar.bookmarks">Bookmarks</string>
<string name="button_bar.browse">Media Library</string>
<string name="button_bar.chat">Chat</string>
@ -234,8 +232,6 @@
<string name="settings.max_bitrate_wifi">Max Bitrate - Wi-Fi</string>
<string name="settings.max_bitrate_pinning">Max Bitrate - When pinning a song permanently</string>
<string name="settings.max_songs">Max Songs</string>
<string name="settings.media_button_summary">Respond to phone, headset and Bluetooth media buttons</string>
<string name="settings.media_button_title">Media Buttons</string>
<string name="settings.network_timeout">Network Timeout</string>
<string name="settings.network_timeout_105000">105 seconds</string>
<string name="settings.network_timeout_120000">120 seconds</string>

View File

@ -72,12 +72,6 @@
a:summary="@string/settings.show_artist_picture_summary"
a:title="@string/settings.show_artist_picture"
app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="true"
a:key="@string/setting_key.media_buttons"
a:summary="@string/settings.media_button_summary"
a:title="@string/settings.media_button_title"
app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="true"
a:key="@string/setting_key.download_transition"