mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Buttons & instant mix
This commit is contained in:
parent
ee0c73f0e2
commit
11e28a605c
6 changed files with 184 additions and 18 deletions
|
|
@ -4,13 +4,16 @@ import android.content.Context
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -21,9 +24,11 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.instantMixApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -35,6 +40,7 @@ class MusicService
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||||
val state: StateFlow<MusicServiceState> = _state
|
val state: StateFlow<MusicServiceState> = _state
|
||||||
|
|
@ -53,6 +59,22 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches instant mix items, replaces the queue, and begins playback
|
||||||
|
*/
|
||||||
|
suspend fun startInstantMix(itemId: UUID) {
|
||||||
|
val items =
|
||||||
|
api.instantMixApi
|
||||||
|
.getInstantMixFromItem(
|
||||||
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
itemId = itemId,
|
||||||
|
limit = 200,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
).content.items
|
||||||
|
.map { BaseItem(it, false) }
|
||||||
|
setQueue(items, false)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setQueue(
|
suspend fun setQueue(
|
||||||
items: List<BaseItem>,
|
items: List<BaseItem>,
|
||||||
shuffled: Boolean,
|
shuffled: Boolean,
|
||||||
|
|
@ -138,8 +160,6 @@ private class MusicPlayerListener(
|
||||||
Timber.v("MusicPlayerListener onIsPlayingChanged")
|
Timber.v("MusicPlayerListener onIsPlayingChanged")
|
||||||
state.update {
|
state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
// TODO this is hack to force the UI to update, but is very inefficient
|
|
||||||
queue = PlayerMediaItemList(player),
|
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -152,24 +172,38 @@ private class MusicPlayerListener(
|
||||||
Timber.v("MusicPlayerListener onMediaItemTransition")
|
Timber.v("MusicPlayerListener onMediaItemTransition")
|
||||||
state.update {
|
state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
queue = PlayerMediaItemList(player),
|
|
||||||
currentIndex = player.currentMediaItemIndex,
|
currentIndex = player.currentMediaItemIndex,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTimelineChanged(
|
||||||
|
timeline: Timeline,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
Timber.v("MusicPlayerListener onTimelineChanged")
|
||||||
|
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
queue = PlayerMediaItemList(player),
|
||||||
|
currentIndex = player.currentMediaItemIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PlayerMediaItemList(
|
private class PlayerMediaItemList(
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
) : AbstractList<AudioItem>() {
|
) : AbstractList<AudioItem>() {
|
||||||
override fun get(index: Int): AudioItem {
|
override fun get(index: Int): AudioItem {
|
||||||
Timber.v("get %s", index)
|
// Timber.v("get %s", index)
|
||||||
return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
|
return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
|
||||||
}
|
}
|
||||||
|
|
||||||
override val size: Int
|
override val size: Int
|
||||||
get() {
|
get() {
|
||||||
Timber.v("size %s", player.mediaItemCount)
|
// Timber.v("size %s", player.mediaItemCount)
|
||||||
return player.mediaItemCount
|
return player.mediaItemCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.ui.focus.FocusState
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -59,6 +61,7 @@ import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
|
@ -68,6 +71,7 @@ import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -165,6 +169,17 @@ class AlbumViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFavorite(favorite: Boolean) =
|
||||||
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
|
val album =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it, false) }
|
||||||
|
_state.update { it.copy(album = album) }
|
||||||
|
}
|
||||||
|
|
||||||
fun play(
|
fun play(
|
||||||
shuffled: Boolean,
|
shuffled: Boolean,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
|
|
@ -180,6 +195,14 @@ class AlbumViewModel
|
||||||
musicService.setQueue(songsToAdd, shuffled)
|
musicService.setQueue(songsToAdd, shuffled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startInstantMix() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.v("Starting instant mix for %s", itemId)
|
||||||
|
musicService.startInstantMix(itemId)
|
||||||
|
navigationManager.navigateTo(Destination.NowPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AlbumState(
|
data class AlbumState(
|
||||||
|
|
@ -194,7 +217,7 @@ data class AlbumState(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumDetails(
|
fun AlbumDetailsPage(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AlbumViewModel =
|
viewModel: AlbumViewModel =
|
||||||
|
|
@ -217,6 +240,9 @@ fun AlbumDetails(
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
val album = state.album!!
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -233,25 +259,38 @@ fun AlbumDetails(
|
||||||
.padding(bottom = 32.dp),
|
.padding(bottom = 32.dp),
|
||||||
) {
|
) {
|
||||||
AlbumHeader(
|
AlbumHeader(
|
||||||
album = state.album!!,
|
album = album,
|
||||||
imageUrl = state.imageUrl,
|
imageUrl = state.imageUrl,
|
||||||
overviewOnClick = {},
|
overviewOnClick = {},
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
AlbumButtons(
|
MusicExpandableButtons(
|
||||||
onClickPlay = { viewModel.play(it, 0) },
|
actions =
|
||||||
onClickAddToPlaylist = {},
|
remember {
|
||||||
onClickGoToArtist = {},
|
MusicButtonActions(
|
||||||
onClickMore = { },
|
onClickPlay = { viewModel.play(it, 0) },
|
||||||
buttonOnFocusChanged = {},
|
onClickInstantMix = viewModel::startInstantMix,
|
||||||
modifier =
|
onClickFavorite = { viewModel.setFavorite(!album.favorite) },
|
||||||
Modifier.onFocusChanged {
|
onClickMore = {
|
||||||
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
// TODO
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
favorite = album.favorite,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.songs),
|
||||||
|
)
|
||||||
|
}
|
||||||
itemsIndexed(state.songs) { index, song ->
|
itemsIndexed(state.songs) { index, song ->
|
||||||
SongListItem(
|
SongListItem(
|
||||||
song = song,
|
song = song,
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MusicExpandableButtons(
|
||||||
|
actions: MusicButtonActions,
|
||||||
|
favorite: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
item("play") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.play,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
onClick = { actions.onClickPlay.invoke(false) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("shuffle") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.shuffle,
|
||||||
|
iconStringRes = R.string.fa_shuffle,
|
||||||
|
onClick = { actions.onClickPlay.invoke(true) },
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("instant_mix") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.instant_mix,
|
||||||
|
iconStringRes = R.string.fa_compass,
|
||||||
|
onClick = actions.onClickInstantMix,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("favorite") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
|
||||||
|
iconStringRes = R.string.fa_heart,
|
||||||
|
onClick = actions.onClickFavorite,
|
||||||
|
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item("more") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.more,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = Icons.Default.MoreVert,
|
||||||
|
onClick = { actions.onClickMore.invoke() },
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MusicButtonActions(
|
||||||
|
val onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
|
val onClickInstantMix: () -> Unit,
|
||||||
|
val onClickFavorite: () -> Unit,
|
||||||
|
val onClickMore: () -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -29,7 +29,7 @@ import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails
|
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
|
import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
|
|
@ -212,7 +212,7 @@ fun DestinationContent(
|
||||||
|
|
||||||
BaseItemKind.MUSIC_ALBUM -> {
|
BaseItemKind.MUSIC_ALBUM -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
AlbumDetails(
|
AlbumDetailsPage(
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,5 @@
|
||||||
<string name="fa_clock" translatable="false"></string>
|
<string name="fa_clock" translatable="false"></string>
|
||||||
<string name="fa_bell" translatable="false"></string>
|
<string name="fa_bell" translatable="false"></string>
|
||||||
<string name="fa_check" translatable="false"></string>
|
<string name="fa_check" translatable="false"></string>
|
||||||
|
<string name="fa_compass" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,7 @@
|
||||||
<string name="songs">Songs</string>
|
<string name="songs">Songs</string>
|
||||||
<string name="go_to_artist">Go to artist</string>
|
<string name="go_to_artist">Go to artist</string>
|
||||||
<string name="now_playing">Now playing</string>
|
<string name="now_playing">Now playing</string>
|
||||||
|
<string name="instant_mix">Instant mix</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue