Skip to content

Commit

Permalink
Add new experimental picture viewer (#1829)
Browse files Browse the repository at this point in the history
* Add KeyHandler

* Add PictureViewerFragment to replace PhotoPlayerActivity

* Address review feedback
  • Loading branch information
nielsvanvelzen authored Aug 11, 2022
1 parent 21769a9 commit fa94dba
Show file tree
Hide file tree
Showing 15 changed files with 681 additions and 0 deletions.
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@
android:name=".ui.itemdetail.PhotoPlayerActivity"
android:screenOrientation="landscape" />

<activity android:name=".ui.picture.PictureViewerActivity" />

<activity
android:name=".ui.itemdetail.ItemListActivity"
android:screenOrientation="landscape" />
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
Expand Down Expand Up @@ -93,6 +94,7 @@ val appModule = module {
viewModel { UserLoginViewModel(get(), get(), get()) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
viewModel { PictureViewerViewModel(get()) }

single { BackgroundService(get(), get(), get(), get()) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var playbackRewriteEnabled = booleanPreference("playback_new", false)

/**
* Use PictureViewer rewrite
*/
var pictureViewerRewriteEnabled = booleanPreference("picture_viewer_new", false)

/**
* When to show the clock.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.koin.java.KoinJavaComponent.inject;

import android.animation.Animator;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
Expand Down Expand Up @@ -34,7 +35,9 @@

import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.databinding.ActivityPhotoPlayerBinding;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.ui.picture.PictureViewerActivity;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.util.ImageHelper;
Expand Down Expand Up @@ -71,11 +74,20 @@ public class PhotoPlayerActivity extends FragmentActivity {
Handler handler;
private Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
private Lazy<MediaManager> mediaManager = inject(MediaManager.class);
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

boolean pictureViewerRewriteEnabled = userPreferences.getValue().get(UserPreferences.Companion.getPictureViewerRewriteEnabled());
if (pictureViewerRewriteEnabled) {
Intent intent = PictureViewerActivity.Companion.createIntent(this, ModelCompat.asSdk(mediaManager.getValue().getCurrentMediaItem().getBaseItem()), getIntent().getBooleanExtra("Play", false));
startActivity(intent);
finishAfterTransition();
return;
}

ActivityPhotoPlayerBinding binding = ActivityPhotoPlayerBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.jellyfin.androidtv.ui.picture

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.androidx.viewmodel.ext.android.viewModel

class PictureViewerActivity : FragmentActivity(R.layout.fragment_content_view) {
companion object {
const val EXTRA_ITEM_ID = "item_id"
const val EXTRA_AUTO_PLAY = "auto_play"

fun createIntent(context: Context, item: BaseItemDto, autoPlay: Boolean): Intent {
require(item.type == BaseItemKind.PHOTO) { "Expected item of type PHOTO but got ${item.type}" }

return Intent(context, PictureViewerActivity::class.java).apply {
putExtra(EXTRA_ITEM_ID, item.id.toString())
putExtra(EXTRA_AUTO_PLAY, autoPlay)
}
}
}

private val pictureViewerViewModel by viewModel<PictureViewerViewModel>()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Load requested item in viewmodel
lifecycleScope.launch {
val itemId = requireNotNull(intent.extras?.getString(EXTRA_ITEM_ID)?.toUUIDOrNull())
pictureViewerViewModel.loadItem(itemId)

val autoPlay = intent.extras?.getBoolean(EXTRA_AUTO_PLAY) == true
if (autoPlay) pictureViewerViewModel.startPresentation()
}

// Show fragment
supportFragmentManager.commit {
add<PictureViewerFragment>(R.id.content_view)
}
}

// Forward key events to fragments
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean = supportFragmentManager.fragments
.filter { it.isVisible }
.filterIsInstance<View.OnKeyListener>()
.any { it.onKey(currentFocus, keyCode, event) }

override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean =
onKeyEvent(keyCode, event) || super.onKeyDown(keyCode, event)

override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean =
onKeyEvent(keyCode, event) || super.onKeyUp(keyCode, event)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package org.jellyfin.androidtv.ui.picture

import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.databinding.FragmentPictureViewerBinding
import org.jellyfin.androidtv.ui.AsyncImageView
import org.jellyfin.androidtv.util.createKeyHandler
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel

class PictureViewerFragment : Fragment(), View.OnKeyListener {
companion object {
/**
* The download API is used by jellyfin-web when loading images. Unfortunately with our
* current code it doesn't work for the app. Larger files (gifs, large photos etc.) can
* cause the app to go out of memory. This is mostly caught by Glide but it ends up
* never displaying the picture in those cases.
*
* This toggle is left in the code in case we migrate to a different image processor that
* potentially fixes the issue.
*/
const val USE_DOWNLOAD_API = false
}

private val pictureViewerViewModel by sharedViewModel<PictureViewerViewModel>()
private val api by inject<ApiClient>()
private lateinit var binding: FragmentPictureViewerBinding

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentPictureViewerBinding.inflate(inflater, container, false)
binding.actionPrevious.setOnClickListener { pictureViewerViewModel.showPrevious() }
binding.actionNext.setOnClickListener { pictureViewerViewModel.showNext() }
binding.actionPlayPause.setOnClickListener { pictureViewerViewModel.togglePresentation() }
binding.root.setOnClickListener { toggleActions() }
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

lifecycleScope.launch {
pictureViewerViewModel.currentItem.filterNotNull().collect { item ->
binding.itemSwitcher.getNextView<AsyncImageView>().load(item)
binding.itemSwitcher.showNextView()
}
}

lifecycleScope.launch {
pictureViewerViewModel.presentationActive.collect { active ->
binding.actionPlayPause.isActivated = active
}
}
}

private val keyHandler = createKeyHandler {
keyDown(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE)
.body { pictureViewerViewModel.togglePresentation() }

keyDown(KeyEvent.KEYCODE_DPAD_LEFT)
.condition { !binding.actions.isVisible }
.body { pictureViewerViewModel.showPrevious() }

keyDown(KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
.body { pictureViewerViewModel.showPrevious() }

keyDown(KeyEvent.KEYCODE_DPAD_RIGHT)
.condition { !binding.actions.isVisible }
.body { pictureViewerViewModel.showNext() }

keyDown(KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_NEXT)
.body { pictureViewerViewModel.showNext() }

keyDown(
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER,
)
.condition { !binding.actions.isVisible }
.body { showActions() }

keyDown(KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_BACK)
.condition { binding.actions.isVisible }
.body { hideActions() }
}

override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean = keyHandler.onKey(event)

private var focusedActionView: View? = null
fun showActions(): Boolean {
if (binding.actions.isVisible) return false

binding.actions.isVisible = true
if (focusedActionView?.requestFocus() != true) binding.actionPlayPause.requestFocus()
binding.actions.startAnimation(AnimationUtils.loadAnimation(context, R.anim.fade_in))
return true
}

fun hideActions(): Boolean {
if (!binding.actions.isVisible) return false
binding.actions.startAnimation(AnimationUtils.loadAnimation(context, R.anim.fade_out).apply {
setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) = Unit

override fun onAnimationEnd(animation: Animation?) {
focusedActionView = binding.actions.findFocus()
binding.actions.isGone = true
}

override fun onAnimationRepeat(animation: Animation?) = Unit
})
})
return true
}

fun toggleActions(): Boolean {
return if (binding.actions.isVisible) hideActions()
else showActions()
}

private fun AsyncImageView.load(item: BaseItemDto) {
val url = when (USE_DOWNLOAD_API) {
true -> api.libraryApi.getDownloadUrl(
itemId = item.id,
includeCredentials = true, // TODO send authentication via header
)
false -> api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.PRIMARY,
tag = item.imageTags?.get(ImageType.PRIMARY),
// Ask the server to downscale the image to avoid the app going out of memory
// unfortunately this can be a bit slow for larger files
maxWidth = context.resources.displayMetrics.widthPixels,
maxHeight = context.resources.displayMetrics.heightPixels,
)
}

load(
url = url,
blurHash = item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(item.imageTags?.get(ImageType.PRIMARY)),
aspectRatio = item.primaryImageAspectRatio ?: 1.0,
)
}
}
Loading

0 comments on commit fa94dba

Please sign in to comment.