Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.content.Intent
import android.content.res.Configuration
import android.media.AudioManager
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
Expand Down Expand Up @@ -104,8 +103,7 @@ class PlayerActivity :
PlaybackFileType.AUDIO -> AudioPlayerView(this)
PlaybackFileType.VIDEO -> VideoPlayerView(this)
}
val moreButton = playerView.findViewById<View>(R.id.more)
moreButton.setOnClickListener { viewModel.onMoreButtonClick() }
playerView.onMoreClick = { viewModel.onMoreButtonClick() }
setContentView(playerView)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ class PlayerProgressIndicator @JvmOverloads constructor(
private var playbackFile: PlaybackFile? = null

init {
indicatorTrackGapSize = 0
trackStopIndicatorSize = 0
if (!isInEditMode) {
(context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
}
Expand Down
27 changes: 20 additions & 7 deletions app/src/main/java/com/nextcloud/client/player/ui/PlayerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ package com.nextcloud.client.player.ui

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.nextcloud.client.player.media3.PlaybackModel
Expand Down Expand Up @@ -63,22 +63,35 @@ abstract class PlayerView @JvmOverloads constructor(
protected val activity: AppCompatActivity by lazy { context as AppCompatActivity }
protected val windowWrapper: WindowWrapper by lazy { WindowWrapper(activity.window) }

protected val topBar: View by lazy { findViewById(R.id.topBar) }
protected val titleTextView: TextView by lazy { findViewById(R.id.title) }
protected val topBar: MaterialToolbar by lazy { findViewById(R.id.topBar) }
protected val playerPager: PlayerPager by lazy { findViewById(R.id.playerPager) }
protected val playerControlView: PlayerControlView by lazy { findViewById(R.id.playerControlView) }

var onMoreClick: (() -> Unit)? = null

init {
inflate(context, layoutRes, this)
if (!isInEditMode) {
(context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
playerPager.initialize(activity.supportFragmentManager, activity.lifecycle, createFragment)
playerPager.onItemSelected = { playbackModel.switchToFile(it) }
playerControlView.navigator = playerPager
findViewById<View>(R.id.back).setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() }
topBar.setNavigationOnClickListener { activity.onBackPressedDispatcher.onBackPressed() }
topBar.setOnMenuItemClickListener { menuItem ->
val isMore = menuItem.itemId == R.id.action_more
if (isMore) {
onMoreClick?.invoke()
}
isMore
}
}
}

protected fun applyTopBarInsets(insets: Insets) {
val actionInset = resources.getDimensionPixelSize(R.dimen.standard_quarter_padding)
topBar.setPadding(insets.left, insets.top, insets.right + actionInset, 0)
}

@CallSuper
open fun onStart() {
val state = playbackModel.state
Expand Down Expand Up @@ -151,10 +164,10 @@ abstract class PlayerView @JvmOverloads constructor(

if (state.currentItemState != null) {
val file = state.currentItemState.file
titleTextView.text = file.getNameWithoutExtension()
topBar.title = file.getNameWithoutExtension()
playerPager.setCurrentItem(file)
} else {
titleTextView.text = ""
topBar.title = ""
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class AudioPlayerView(context: Context) : PlayerView(context) {
val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets)
val insets = windowInsetsCompat.getInsets(Type.systemBars() or Type.displayCutout())

topBar.setPadding(insets.left, insets.top, insets.right, 0)
applyTopBarInsets(insets)
playerPager.setPadding(insets.left, 0, insets.right, 0)
playerControlView.setPadding(insets.left, 0, insets.right, insets.bottom)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@
package com.nextcloud.client.player.ui.control

import android.content.Context
import android.content.res.ColorStateList
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.WindowInsets
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.flowWithLifecycle
import com.nextcloud.android.common.ui.color.ColorUtil
import com.nextcloud.client.player.media3.PlaybackModel
import com.nextcloud.client.player.model.state.PlaybackItemState
import com.nextcloud.client.player.model.state.PlaybackState
Expand All @@ -29,6 +28,7 @@ import com.nextcloud.client.player.model.state.RepeatMode
import com.nextcloud.client.player.ui.MediaNavigator
import com.owncloud.android.R
import com.owncloud.android.databinding.PlayerControlViewBinding
import com.owncloud.android.utils.theme.ViewThemeUtils
import dagger.android.HasAndroidInjector
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -40,15 +40,9 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds

private const val INDETERMINATE_TIME = "--:--"
private const val TAG_CLICK_COMMAND_PLAY = "TAG_CLICK_COMMAND_PLAY"
private const val TAG_CLICK_COMMAND_PAUSE = "TAG_CLICK_COMMAND_PAUSE"
private const val TAG_CLICK_COMMAND_REPEAT = "TAG_CLICK_COMMAND_REPEAT"
private const val TAG_CLICK_COMMAND_DO_NOT_REPEAT = "TAG_CLICK_COMMAND_DO_NOT_REPEAT"
private const val TAG_CLICK_COMMAND_SHUFFLE = "TAG_CLICK_COMMAND_SHUFFLE"
private const val TAG_CLICK_COMMAND_DO_NOT_SHUFFLE = "TAG_CLICK_COMMAND_DO_NOT_SHUFFLE"
private const val TAG_CLICK_COMMAND_UNKNOWN = "TAG_CLICK_COMMAND_UNKNOWN"

private const val PROGRESS_CHANGE_DEBOUNCE_MS = 200L
private const val DEFAULT_MIN_PROGRESS = 0
Expand All @@ -68,17 +62,42 @@ class PlayerControlView @JvmOverloads constructor(
@Inject
lateinit var playbackModel: PlaybackModel

private val seekBarProgressChangeFlow = MutableSharedFlow<Int>(extraBufferCapacity = 1)
@Inject
lateinit var viewThemeUtils: ViewThemeUtils

@Inject
lateinit var colorUtil: ColorUtil

private val seekPositionFlow = MutableSharedFlow<Int>(extraBufferCapacity = 1)
private var viewScope: CoroutineScope? = null

val binding = PlayerControlViewBinding.inflate(LayoutInflater.from(context), this, true)

private val serverPrimaryColor: Int by lazy { viewThemeUtils.getScheme(context).sourceColorArgb }

private val serverPrimaryTint: ColorStateList by lazy { ColorStateList.valueOf(serverPrimaryColor) }

private val onServerPrimaryTint: ColorStateList by lazy {
ColorStateList.valueOf(colorUtil.getForegroundColorForBackgroundColor(serverPrimaryColor))
}

private val transportIconTint: ColorStateList? by lazy {
ContextCompat.getColorStateList(context, R.color.player_control_icon_tint)
}

private val toggleIconTint: ColorStateList by lazy {
ColorStateList(
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
intArrayOf(serverPrimaryColor, ContextCompat.getColor(context, R.color.player_default_icon_color))
)
}

var navigator: MediaNavigator? = null

init {
if (!isInEditMode) {
(context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
setDefaultTags()
themeControls()
setListeners()
}
}
Expand All @@ -87,7 +106,7 @@ class PlayerControlView @JvmOverloads constructor(
super.onAttachedToWindow()
if (!isInEditMode) {
viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
collectSeekBarChanges()
collectSeekPositions()
}
}

Expand Down Expand Up @@ -126,125 +145,99 @@ class PlayerControlView @JvmOverloads constructor(
render(state)
}

private fun setDefaultTags() {
binding.ivPlayPause.tag = TAG_CLICK_COMMAND_UNKNOWN
binding.ivRandom.tag = TAG_CLICK_COMMAND_UNKNOWN
binding.ivRepeat.tag = TAG_CLICK_COMMAND_UNKNOWN
private fun themeControls() {
binding.run {
ivPrevious.iconTint = transportIconTint
ivNext.iconTint = transportIconTint
ivRepeat.iconTint = toggleIconTint
ivRandom.iconTint = toggleIconTint
ivPlayPause.backgroundTintList = serverPrimaryTint
ivPlayPause.iconTint = onServerPrimaryTint

slider.trackActiveTintList = serverPrimaryTint
slider.thumbTintList = serverPrimaryTint
slider.setLabelFormatter { formatTime(it.toInt(), slider.valueTo.toInt()) }
}
}

private fun setListeners() {
binding.ivPlayPause.setOnClickListener {
when (binding.ivPlayPause.tag) {
TAG_CLICK_COMMAND_PLAY -> playbackModel.play()
TAG_CLICK_COMMAND_PAUSE -> playbackModel.pause()
if (binding.ivPlayPause.isChecked) {
playbackModel.play()
} else {
playbackModel.pause()
}
}

binding.ivRepeat.setOnClickListener {
val repeatSingle = binding.ivRepeat.isChecked

// Repeat and Random are mutually exclusive
playbackModel.setShuffle(false)

when (binding.ivRepeat.tag) {
TAG_CLICK_COMMAND_REPEAT -> playbackModel.setRepeatMode(RepeatMode.SINGLE)
TAG_CLICK_COMMAND_DO_NOT_REPEAT -> playbackModel.setRepeatMode(RepeatMode.ALL)
}
playbackModel.setRepeatMode(if (repeatSingle) RepeatMode.SINGLE else RepeatMode.ALL)
}

binding.ivRandom.setOnClickListener {
val shuffle = binding.ivRandom.isChecked

// Repeat and Random are mutually exclusive
playbackModel.setRepeatMode(RepeatMode.OFF)

playbackModel.setShuffle(binding.ivRandom.tag == TAG_CLICK_COMMAND_SHUFFLE)
playbackModel.setShuffle(shuffle)
}

binding.ivNext.setOnClickListener { navigator?.showNext() }

binding.ivPrevious.setOnClickListener { navigator?.showPrevious() }

binding.progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
seekBarProgressChangeFlow.tryEmit(progress)
}
binding.slider.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
seekPositionFlow.tryEmit(value.toInt())
}

override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit

override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
})
}
}

@OptIn(FlowPreview::class)
private fun collectSeekBarChanges() {
private fun collectSeekPositions() {
val viewScope = viewScope ?: return
val lifecycleOwner = (context as? LifecycleOwner) ?: return
seekBarProgressChangeFlow
.debounce(PROGRESS_CHANGE_DEBOUNCE_MS)
seekPositionFlow
.debounce(PROGRESS_CHANGE_DEBOUNCE_MS.milliseconds)
.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach { playbackModel.seekToPosition(it.toLong()) }
.launchIn(viewScope)
}

private fun render(playbackState: PlaybackState) {
renderRepeatButton(playbackState.repeatMode == RepeatMode.SINGLE)
renderShuffleButton(playbackState.shuffle)
renderPlayPauseButton(playbackState.currentItemState?.playerState == PlayerState.PLAYING)
renderNextPreviousButtons()
renderProgressBar(playbackState.currentItemState)
}

private fun renderRepeatButton(repeatSingle: Boolean) {
binding.ivRepeat.iconTint = ContextCompat.getColorStateList(
binding.root.context,
if (repeatSingle) {
R.color.player_accent_color
} else {
R.color.player_default_icon_color
}
)
binding.ivRepeat.tag = if (repeatSingle) TAG_CLICK_COMMAND_DO_NOT_REPEAT else TAG_CLICK_COMMAND_REPEAT
}

private fun renderShuffleButton(shuffle: Boolean) {
binding.ivRandom.iconTint = ContextCompat.getColorStateList(
binding.root.context,
if (shuffle) {
R.color.player_accent_color
} else {
R.color.player_default_icon_color
}
)
binding.ivRandom.tag = if (shuffle) TAG_CLICK_COMMAND_DO_NOT_SHUFFLE else TAG_CLICK_COMMAND_SHUFFLE
}

private fun renderPlayPauseButton(isPlaying: Boolean) {
binding.ivPlayPause.icon = AppCompatResources.getDrawable(
binding.root.context,
if (isPlaying) {
R.drawable.player_ic_pause
} else {
R.drawable.player_ic_play
}
)
binding.ivPlayPause.tag = if (isPlaying) TAG_CLICK_COMMAND_PAUSE else TAG_CLICK_COMMAND_PLAY
}

private fun renderNextPreviousButtons() {
binding.ivRepeat.isChecked = playbackState.repeatMode == RepeatMode.SINGLE
binding.ivRandom.isChecked = playbackState.shuffle
binding.ivPlayPause.isChecked = playbackState.currentItemState?.playerState == PlayerState.PLAYING
binding.ivNext.isEnabled = navigator?.hasNext == true
binding.ivPrevious.isEnabled = navigator?.hasPrevious == true
renderProgress(playbackState.currentItemState)
}

private fun renderProgressBar(playbackItemState: PlaybackItemState?) {
private fun renderProgress(playbackItemState: PlaybackItemState?) {
val enabled = playbackItemState != null && playbackItemState.maxTimeInMilliseconds > DEFAULT_MIN_PROGRESS
val max = if (enabled) playbackItemState.maxTimeInMilliseconds.toInt() else DEFAULT_MAX_PROGRESS
val progress = if (enabled) playbackItemState.currentTimeInMilliseconds.toInt() else DEFAULT_MIN_PROGRESS
binding.progressBar.isEnabled = enabled
binding.progressBar.max = max
binding.progressBar.progress = progress
binding.slider.isEnabled = enabled
moveSliderTo(max.toFloat(), progress.toFloat())
binding.tvElapsed.text = if (enabled) formatTime(progress, max) else INDETERMINATE_TIME
binding.tvTotalTime.text = if (enabled) formatTime(max, max) else INDETERMINATE_TIME
}

private fun moveSliderTo(valueTo: Float, value: Float) {
binding.slider.run {
if (this.valueTo != valueTo) {
this.value = valueFrom
this.valueTo = valueTo
}
this.value = value.coerceIn(valueFrom, this.valueTo)
}
}

private fun formatTime(current: Int, max: Int): String {
val seconds = current / MILLISECONDS_IN_SECOND
val minutes = seconds / SECONDS_IN_MINUTE
Expand Down
Loading
Loading