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  ### Artist page  ### Album details  ### Album is playing/queued  ### Now playing page  ### Now playing queue  </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:
parent
8a37d63d09
commit
f2570d4ab0
46 changed files with 6342 additions and 436 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ val supportedCollectionTypes =
|
|||
CollectionType.LIVETV,
|
||||
CollectionType.MUSICVIDEOS,
|
||||
CollectionType.FOLDERS,
|
||||
CollectionType.MUSIC,
|
||||
null, // Mixed
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue