Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new experimental picture viewer #1829

Merged
merged 3 commits into from
Aug 11, 2022
Merged
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
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 @@ -10,6 +10,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 @@ -89,6 +90,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 @@ -163,6 +163,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)

Check warning

Code scanning / Android Lint

Using inlined constants on older versions

Field requires API level 23 (current min is 21): android.view.KeyEvent#KEYCODE_MEDIA_SKIP_BACKWARD
.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)

Check warning

Code scanning / Android Lint

Using inlined constants on older versions

Field requires API level 23 (current min is 21): android.view.KeyEvent#KEYCODE_MEDIA_SKIP_FORWARD
.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