Add support for music (#1059)

## Description
Adds support for music playback

### Features
- Adds a "Now Playing" page which is accessible from the nav drawer
while music is playing
- Manage the playback queue, add/remove/re-order songs
- Supports synced & non-synced lyrics
- Scroll through lyrics
- Click on line for synced lyrics to seek to that position

### User interface
- Customize what's shown on the "Now Playing" page
- Show album cover art, backdrops, and lyrics
- Show a basic bar visualizer
- Search for albums, artists, & songs
- Links between albums, artists, playlists, & music videos when metadata
is available
- Throughout the app, see which song is playing and which ones are in
the queue

Note: lyrics are synced by line. `10.11` added support for syncing by
word, but Wholphin still supports `10.10`

### Related issues
Closes #2 
Closes #267 

### Testing
Emulator & shield testing

## Screenshots
<details><summary>Click to see screenshots</summary>
<p>

### Album grid
![music_album_grid
Large](https://github.com/user-attachments/assets/4573ad32-0cd7-49db-94db-de37ceb6f641)

### Artist page
![music_artist
Large](https://github.com/user-attachments/assets/fdf3b7b9-5c40-434d-867d-7d56e0b3430e)

### Album details
![music_album_details
Large](https://github.com/user-attachments/assets/494d1a76-9581-4a07-a8ee-5c0957f542e0)

### Album is playing/queued
![music_album_queued
Large](https://github.com/user-attachments/assets/55c72cd2-10a9-411d-8bae-b232150db506)

### Now playing page
![now_playing
Large](https://github.com/user-attachments/assets/b773b1bc-71fd-4789-8509-cebfc0d0ffe5)

### Now playing queue
![now_playing_queue
Large](https://github.com/user-attachments/assets/db244462-e33d-452a-93dc-dd2123a4862d)

</p>
</details> 

## AI or LLM usage
None

## Try it out

See instructions at
https://github.com/damontecres/Wholphin/releases/tag/develop-music
This commit is contained in:
Ray 2026-03-24 13:42:18 -04:00 • committed by GitHub
parent 8a37d63d09
commit f2570d4ab0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 6342 additions and 436 deletions

View file

@ -1,9 +1,26 @@
package com.github.damontecres.wholphin.util
import java.util.function.IntFunction
import java.util.function.Predicate
interface BlockingList<T> : List<T> {
suspend fun getBlocking(index: Int): T
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
companion object {
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
}
}
private class BlockingListWrapper<T>(
private val list: List<T>,
) : BlockingList<T>,
List<T> by list {
override suspend fun getBlocking(index: Int): T = get(index)
override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) }
@Deprecated("Deprecated")
override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator)
}

View file

@ -32,6 +32,7 @@ val supportedCollectionTypes =
CollectionType.LIVETV,
CollectionType.MUSICVIDEOS,
CollectionType.FOLDERS,
CollectionType.MUSIC,
null, // Mixed
)

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds
class TrackActivityPlaybackListener(
private val api: ApiClient,
private val player: Player,
val playback: CurrentPlayback,
val itemPlayback: ItemPlayback,
private val getState: () -> PlaybackItemState?,
) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask =
@ -50,44 +51,50 @@ class TrackActivityPlaybackListener(
fun init() {
launch("reportPlaybackStart") {
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = true,
itemId = itemPlayback.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
),
)
val delay = 5.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing
TIMER.schedule(task, delay, delay)
initialized = true
getState.invoke()?.let { state ->
Timber.v("reportPlaybackStart for ${state.itemId}")
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = true,
itemId = state.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
val delay = 5.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing
TIMER.schedule(task, delay, delay)
initialized = true
}
}
}
fun release() {
// player.removeListener(this)
task.cancel()
TIMER.purge()
val position = player.currentPosition.milliseconds
launch("reportPlaybackStopped") {
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemPlayback.itemId,
positionTicks = position.inWholeTicks,
failed = false,
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
),
)
getState.invoke()?.let { state ->
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = state.itemId,
positionTicks = position.inWholeTicks,
failed = false,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
}
}
}
@ -108,29 +115,31 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) {
launch("saveActivity") {
val calcPosition =
withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition)
getState.invoke()?.let { state ->
val calcPosition =
withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition)
}
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = state.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = state.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = state.audioStreamIndex,
subtitleStreamIndex = state.subtitleStreamIndex,
playSessionId = state.playSessionId,
liveStreamId = state.liveStreamId,
),
)
}
if (calcPosition > 0) {
val isPaused = withContext(Dispatchers.Main) { !player.isPlaying }
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = itemPlayback.itemId,
positionTicks = calcPosition.milliseconds.inWholeTicks,
canSeek = true,
isPaused = isPaused,
isMuted = false,
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
),
)
}
}
}
@ -143,7 +152,7 @@ class TrackActivityPlaybackListener(
try {
block.invoke(this)
} catch (ex: Exception) {
Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId)
Timber.w(ex, "Exception during %s", name)
}
}
}
@ -154,3 +163,24 @@ class TrackActivityPlaybackListener(
private val TIMER by lazy { Timer("$TAG-timer", true) }
}
}
data class PlaybackItemState(
val itemId: UUID,
val playMethod: PlayMethod,
val audioStreamIndex: Int? = null,
val subtitleStreamIndex: Int? = null,
val playSessionId: String? = null,
val liveStreamId: String? = null,
) {
constructor(
playback: CurrentPlayback,
itemPlayback: ItemPlayback,
) : this(
itemId = itemPlayback.itemId,
playMethod = playback.playMethod,
audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
playSessionId = playback.playSessionId,
liveStreamId = playback.liveStreamId,
)
}