Click to see screenshots
### Album grid

### Artist page

### Album details

### Album is playing/queued

### Now playing page

### Now playing queue

## AI or LLM usage
None
## Try it out
See instructions at
https://github.com/damontecres/Wholphin/releases/tag/develop-music
---
.../wholphin/data/model/AudioItem.kt | 43 +
.../wholphin/data/model/BaseItem.kt | 4 +-
.../preferences/AppPreferencesSerializer.kt | 14 +
.../wholphin/services/AppUpgradeHandler.kt | 11 +
.../wholphin/services/BackdropService.kt | 2 +-
.../wholphin/services/MusicService.kt | 519 ++++++++++++
.../wholphin/services/NavDrawerService.kt | 43 +-
.../ui/components/RecommendedContent.kt | 4 +-
.../ui/components/RecommendedMusic.kt | 248 ++++++
.../wholphin/ui/data/SortAndDirection.kt | 45 ++
.../ui/detail/CollectionFolderMusic.kt | 245 ++++++
.../wholphin/ui/detail/DetailUtils.kt | 32 +
.../wholphin/ui/detail/PlaylistDetails.kt | 588 ++++++++++----
.../ui/detail/music/AlbumDetailsPage.kt | 721 +++++++++++++++++
.../ui/detail/music/ArtistDetailsPage.kt | 750 ++++++++++++++++++
.../wholphin/ui/detail/music/BarVisualizer.kt | 54 ++
.../wholphin/ui/detail/music/LyricsContent.kt | 145 ++++
.../ui/detail/music/MusicExpandableButtons.kt | 95 +++
.../ui/detail/music/MusicPreference.kt | 42 +
.../ui/detail/music/MusicViewModel.kt | 142 ++++
.../ui/detail/music/MusicViewOptionsDialog.kt | 133 ++++
.../ui/detail/music/NowPlayingButtons.kt | 320 ++++++++
.../ui/detail/music/NowPlayingOverlay.kt | 229 ++++++
.../ui/detail/music/NowPlayingPage.kt | 480 +++++++++++
.../ui/detail/music/NowPlayingState.kt | 17 +
.../ui/detail/music/NowPlayingViewModel.kt | 406 ++++++++++
.../ui/detail/music/SongListDisplay.kt | 237 ++++++
.../wholphin/ui/detail/music/Utils.kt | 269 +++++++
.../wholphin/ui/main/SearchPage.kt | 140 +++-
.../ui/main/settings/HomeRowPresets.kt | 23 +-
.../main/settings/HomeSettingsFavoriteList.kt | 2 +
.../ui/main/settings/HomeSettingsViewModel.kt | 26 +-
.../wholphin/ui/nav/ApplicationContent.kt | 166 +---
.../damontecres/wholphin/ui/nav/Backdrop.kt | 205 +++++
.../wholphin/ui/nav/Destination.kt | 3 +
.../wholphin/ui/nav/DestinationContent.kt | 36 +-
.../damontecres/wholphin/ui/nav/NavDrawer.kt | 62 +-
.../wholphin/ui/playback/PlaybackControls.kt | 65 +-
.../wholphin/ui/playback/PlaybackViewModel.kt | 8 +-
.../ui/preferences/NavDrawerPreference.kt | 8 +-
.../damontecres/wholphin/util/BlockingList.kt | 17 +
.../damontecres/wholphin/util/Constants.kt | 1 +
.../util/TrackActivityPlaybackListener.kt | 140 ++--
app/src/main/proto/WholphinDataStore.proto | 8 +
app/src/main/res/values/fa_strings.xml | 3 +
app/src/main/res/values/strings.xml | 27 +-
46 files changed, 6342 insertions(+), 436 deletions(-)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt
new file mode 100644
index 00000000..49b3c694
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt
@@ -0,0 +1,43 @@
+package com.github.damontecres.wholphin.data.model
+
+import androidx.compose.runtime.Stable
+import org.jellyfin.sdk.model.extensions.ticks
+import java.util.UUID
+import kotlin.time.Duration
+
+@Stable
+data class AudioItem(
+ val key: Long = keyTracker++,
+ val id: UUID,
+ val albumId: UUID?,
+ val artistId: UUID?,
+ val title: String?,
+ val albumTitle: String?,
+ val artistNames: String?,
+ val runtime: Duration?,
+ val imageUrl: String?,
+ val hasLyrics: Boolean,
+) {
+ companion object {
+ private var keyTracker = 0L
+
+ fun from(
+ item: BaseItem,
+ imageUrl: String?,
+ ): AudioItem =
+ AudioItem(
+ id = item.id,
+ albumId = item.data.albumId,
+ artistId =
+ item.data.artistItems
+ ?.firstOrNull()
+ ?.id,
+ title = item.title,
+ albumTitle = item.data.album,
+ artistNames = item.data.albumArtist,
+ runtime = item.data.runTimeTicks?.ticks,
+ imageUrl = imageUrl,
+ hasLyrics = item.data.hasLyrics == true,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index 3c34851b..7ab11e4b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -8,6 +8,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem
+import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime
@@ -32,7 +33,7 @@ import kotlin.time.Duration
@Stable
data class BaseItem(
val data: BaseItemDto,
- val useSeriesForPrimary: Boolean,
+ val useSeriesForPrimary: Boolean = false,
val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null,
) : CardGridItem {
@@ -57,6 +58,7 @@ data class BaseItem(
when (type) {
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears
+ BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index bf8ab2a4..f55b4ea9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -152,6 +152,15 @@ class AppPreferencesSerializer
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build()
+
+ musicPreferences =
+ MusicPreferences
+ .newBuilder()
+ .apply {
+ showBackdrop = true
+ showLyrics = true
+ showAlbumArt = true
+ }.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
}
+inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
+ update {
+ musicPreferences = musicPreferences.toBuilder().apply(block).build()
+ }
+
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index ca720af3..617944fc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
+import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
@@ -280,5 +281,15 @@ class AppUpgradeHandler
)
}
}
+
+ if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) {
+ appPreferences.updateData {
+ it.updateMusicPreferences {
+ showBackdrop = true
+ showLyrics = true
+ showAlbumArt = true
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
index e0c48cca..832d4137 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
@@ -114,7 +114,7 @@ class BackdropService
}
}
- private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
+ suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
new file mode 100644
index 00000000..be3ceac7
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -0,0 +1,519 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.Timeline
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.session.MediaSession
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.AudioItem
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
+import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
+import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
+import com.github.damontecres.wholphin.ui.onMain
+import com.github.damontecres.wholphin.ui.seekBack
+import com.github.damontecres.wholphin.ui.seekForward
+import com.github.damontecres.wholphin.ui.toServerString
+import com.github.damontecres.wholphin.util.BlockingList
+import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.PlaybackItemState
+import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
+import com.github.damontecres.wholphin.util.profile.Codec
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+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.sockets.subscribe
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.PlayMethod
+import org.jellyfin.sdk.model.api.PlaystateCommand
+import org.jellyfin.sdk.model.api.PlaystateMessage
+import org.jellyfin.sdk.model.extensions.ticks
+import org.jellyfin.sdk.model.serializer.toUUID
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(UnstableApi::class)
+@Singleton
+class MusicService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
+ @param:DefaultCoroutineScope private val defaultScope: CoroutineScope,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val imageUrlService: ImageUrlService,
+ ) {
+ private val _state = MutableStateFlow(MusicServiceState.EMPTY)
+ val state: StateFlow