diff --git a/app/build.gradle b/app/build.gradle index b5c31eee..645bec73 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,6 +87,12 @@ dependencies { implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-compiler:$hilt_version" + // ExoPlayer + def exoplayer_version = "2.14.1" + implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-dash:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" + // Testing testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 610b4cf6..91e46363 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,13 +12,14 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Jellyfin"> + + android:theme="@style/Theme.JellyfinSplashScreen" + android:windowSoftInputMode="adjustPan"> diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt new file mode 100644 index 00000000..30a0140c --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt @@ -0,0 +1,47 @@ +package dev.jdtech.jellyfin + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.navigation.navArgs +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.ui.PlayerView +import com.google.android.exoplayer2.util.MimeTypes +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel + +@AndroidEntryPoint +class PlayerActivity : AppCompatActivity() { + private val viewModel: PlayerActivityViewModel by viewModels() + + private val args: PlayerActivityArgs by navArgs() + + private lateinit var playerView: PlayerView + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d("PlayerActivity", "onCreate") + setContentView(R.layout.activity_player) + + playerView = findViewById(R.id.video_view) + + viewModel.player.observe(this, { + playerView.player = it + }) + + if (viewModel.player.value == null) { + viewModel.initializePlayer(args.itemId) + } + } + + override fun onDestroy() { + super.onDestroy() + Log.d("PlayerActivity", "onDestroy") + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt index 441b58bf..2284d04d 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt @@ -32,6 +32,8 @@ class JellyfinApi(context: Context, baseUrl: String) { val userLibraryApi = UserLibraryApi(api) val showsApi = TvShowsApi(api) val sessionApi = SessionApi(api) + val videosApi = VideosApi(api) + val mediaInfoApi = MediaInfoApi(api) companion object { @Volatile diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index dfad556e..eba68249 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -16,6 +16,7 @@ import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import org.jellyfin.sdk.model.api.BaseItemDto +import java.util.* @AndroidEntryPoint class MediaInfoFragment : Fragment() { @@ -70,6 +71,10 @@ class MediaInfoFragment : Fragment() { }, fixedWidth = true) binding.peopleRecyclerView.adapter = PersonListAdapter() + binding.playButton.setOnClickListener { + navigateToPlayerActivity(args.itemId) + } + viewModel.loadData(args.itemId) } @@ -91,4 +96,10 @@ class MediaInfoFragment : Fragment() { ) ) } + + private fun navigateToPlayerActivity(itemId: UUID) { + findNavController().navigate( + MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(itemId) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt index 84949023..ff2fcb74 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt @@ -14,4 +14,6 @@ interface JellyfinRepository { suspend fun getNextUp(seriesId: UUID): List suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, fields: List? = null): List + + suspend fun getStreamUrl(itemId: UUID): String } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt index b751a0ef..94d3c8b6 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt @@ -3,8 +3,7 @@ package dev.jdtech.jellyfin.repository import dev.jdtech.jellyfin.api.JellyfinApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.* import java.util.* class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRepository { @@ -60,4 +59,49 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep } return episodes } + + override suspend fun getStreamUrl(itemId: UUID): String { + val streamUrl: String + withContext(Dispatchers.IO) { + /*val mediaInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( + itemId, PlaybackInfoDto( + userId = jellyfinApi.userId!!, + deviceProfile = DeviceProfile( + name = "Direct play all", + maxStaticBitrate = 1_000_000_000, + maxStreamingBitrate = 1_000_000_000, + codecProfiles = listOf(), + containerProfiles = listOf(), + directPlayProfiles = listOf( + DirectPlayProfile( + type = DlnaProfileType.VIDEO + ), DirectPlayProfile(type = DlnaProfileType.AUDIO) + ), + transcodingProfiles = listOf(), + responseProfiles = listOf(), + enableAlbumArtInDidl = false, + enableMsMediaReceiverRegistrar = false, + enableSingleAlbumArtLimit = false, + enableSingleSubtitleLimit = false, + ignoreTranscodeByteRangeRequests = false, + maxAlbumArtHeight = 1_000_000_000, + maxAlbumArtWidth = 1_000_000_000, + requiresPlainFolders = false, + requiresPlainVideoItems = false, + timelineOffsetSeconds = 0 + ), + startTimeTicks = null, + audioStreamIndex = null, + subtitleStreamIndex = null, + maxStreamingBitrate = 1_000_000_000, + ) + ).content*/ + streamUrl = jellyfinApi.videosApi.getVideoStreamUrl( + itemId, + static = true, + mediaSourceId = itemId.toString() + ) + } + return streamUrl + } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt new file mode 100644 index 00000000..41654374 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt @@ -0,0 +1,106 @@ +package dev.jdtech.jellyfin.viewmodels + +import android.app.Application +import android.content.Context +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.util.MimeTypes +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.repository.JellyfinRepository +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class PlayerActivityViewModel +@Inject +constructor( + private val application: Application, + private val jellyfinRepository: JellyfinRepository +) : ViewModel() { + private var _player = MutableLiveData() + var player: LiveData = _player + + private var playWhenReady = true + private var currentWindow = 0 + private var playbackPosition: Long = 0 + private var playbackStateListener: PlaybackStateListener + + init { + playbackStateListener = PlaybackStateListener() + } + + fun initializePlayer(itemId: UUID) { + if (player.value == null) { + val trackSelector = DefaultTrackSelector(application) + trackSelector.parameters.buildUpon().setMaxVideoSizeSd() + _player.value = SimpleExoPlayer.Builder(application) + .setTrackSelector(trackSelector) + .build() + } + + player.value?.addListener(playbackStateListener) + + viewModelScope.launch { + val streamUrl = jellyfinRepository.getStreamUrl(itemId) + val mediaItem = + MediaItem.Builder() + .setMediaId(itemId.toString()) + .setUri(streamUrl) + .build() + player.value?.setMediaItem(mediaItem) + } + + player.value?.playWhenReady = playWhenReady + player.value?.seekTo(currentWindow, playbackPosition) + player.value?.prepare() + } + + fun releasePlayer() { + if (player.value != null) { + playWhenReady = player.value!!.playWhenReady + playbackPosition = player.value!!.currentPosition + currentWindow = player.value!!.currentWindowIndex + player.value!!.removeListener(playbackStateListener) + player.value!!.release() + _player.value = null + } + } + + class PlaybackStateListener : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + var stateString = "UNKNOWN_STATE -" + when (state) { + ExoPlayer.STATE_IDLE -> { + stateString = "ExoPlayer.STATE_IDLE -"; + } + ExoPlayer.STATE_BUFFERING -> { + stateString = "ExoPlayer.STATE_BUFFERING -"; + } + ExoPlayer.STATE_READY -> { + stateString = "ExoPlayer.STATE_READY -"; + } + ExoPlayer.STATE_ENDED -> { + stateString = "ExoPlayer.STATE_ENDED -"; + } + } + Log.d("PlayerActivity", "changed state to $stateString") + } + } + + override fun onCleared() { + super.onCleared() + Log.d("PlayerActivity", "onCleared ViewModel") + releasePlayer() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml new file mode 100644 index 00000000..2df147d8 --- /dev/null +++ b/app/src/main/res/layout/activity_player.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index 5972e47d..3ef322e7 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -88,6 +88,9 @@ + + + + \ No newline at end of file