diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index bd9210d0..3f6b7b7f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -33,6 +33,7 @@ import com.github.damontecres.dolphin.ui.nav.ApplicationContent import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.theme.DolphinTheme +import com.github.damontecres.dolphin.util.UpdateChecker import com.github.damontecres.dolphin.util.profile.createDeviceProfile import dagger.hilt.android.AndroidEntryPoint import okhttp3.OkHttpClient @@ -54,6 +55,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var navigationManager: NavigationManager + @Inject + lateinit var updateChecker: UpdateChecker + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -122,6 +126,15 @@ class MainActivity : AppCompatActivity() { } val backStack = rememberNavBackStack(initialDestination) navigationManager.backStack = backStack + if (appPreferences.autoCheckForUpdates) { + LaunchedEffect(Unit) { + try { + updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) + } catch (ex: Exception) { + Timber.w(ex, "Failed to check for update") + } + } + } ApplicationContent( user = user, server = server, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index 08ad61dd..6afd8752 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -6,6 +6,7 @@ import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent import android.view.WindowManager +import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement @@ -29,6 +30,8 @@ import coil3.request.ErrorResult import com.github.damontecres.dolphin.ui.data.RowColumn import com.github.damontecres.dolphin.ui.data.RowColumnSaver import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -309,3 +312,21 @@ fun rememberPosition(initialPosition: RowColumn = RowColumn(-1, -1)) = initialPosition, ) } + +/** + * Show a [Toast]. Ensures it runs on the main thread. + */ +suspend fun showToast( + context: Context, + text: CharSequence, + duration: Int, +) = withContext(Dispatchers.Main) { + Toast.makeText(context, text, duration).show() +} + +suspend fun showToast( + context: Context, + text: CharSequence, +) = withContext(Dispatchers.Main) { + Toast.makeText(context, text, Toast.LENGTH_LONG).show() +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt index ec8b6b03..cd7bada9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt @@ -1,14 +1,20 @@ package com.github.damontecres.dolphin.ui.components +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme +import com.github.damontecres.dolphin.ui.tryRequestFocus @Composable fun CircularProgress(modifier: Modifier = Modifier) { @@ -21,13 +27,19 @@ fun CircularProgress(modifier: Modifier = Modifier) { } /** - * Fill the space with a loading indicator + * Fill the space with a loading indicator and take focus */ @Composable fun LoadingPage(modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Box( contentAlignment = Alignment.Center, - modifier = modifier.fillMaxSize(), + modifier = + modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable(), ) { CircularProgressIndicator( color = MaterialTheme.colorScheme.border, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index fda14f00..57da663a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -20,6 +20,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.showToast import com.github.damontecres.dolphin.util.ApiRequestPager import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler @@ -297,9 +298,11 @@ class SeriesViewModel if (nextUp != null) { navigateTo(Destination.Playback(nextUp.id, 0L)) } else { - Toast - .makeText(context, "Could not find an episode to play", Toast.LENGTH_SHORT) - .show() + showToast( + context, + "Could not find an episode to play", + Toast.LENGTH_SHORT, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt index ba1b8415..53945031 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt @@ -41,7 +41,9 @@ import com.github.damontecres.dolphin.ui.playOnClickSound import com.github.damontecres.dolphin.ui.playSoundOnFocus import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.timeRemaining +import com.github.damontecres.dolphin.util.formatSubtitleLang import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @Composable @@ -153,6 +155,14 @@ fun MovieDetailsHeader( ) } } + movie.data.people + ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } + ?.joinToString(", ") { it.name!! } + ?.let { + Text( + text = "Directed by $it", + ) + } // Key-Values Row( modifier = @@ -185,10 +195,7 @@ fun MovieDetailsHeader( ) } } - dto.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() } - ?.mapNotNull { it.language } - ?.joinToString(", ") + formatSubtitleLang(dto.mediaStreams) ?.let { if (it.isNotNullOrBlank()) { TitleValueText( @@ -199,13 +206,6 @@ fun MovieDetailsHeader( } } // TODO add writers, studio, etc to overview dialog -// dto.people?.firstOrNull { it.type == PersonKind.DIRECTOR }?.name?.let { -// TitleValueText( -// stringResource(R.string.director), -// it, -// modifier = Modifier.widthIn(max = 80.dp), -// ) -// } // dto.studios?.letNotEmpty { // TitleValueText( // stringResource(R.string.studios), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt index 8812fcdf..b4bb7cc5 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -15,6 +15,7 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.components.ExpandablePlayButtons import com.github.damontecres.dolphin.ui.components.TitleValueText import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.util.formatSubtitleLang import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -75,10 +76,7 @@ fun FocusedEpisodeFooter( } } - dto.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() } - ?.mapNotNull { it.language } - ?.joinToString(", ") + formatSubtitleLang(dto.mediaStreams) ?.let { if (it.isNotNullOrBlank()) { TitleValueText( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt index 606dea38..552ff0e6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt @@ -193,11 +193,7 @@ class HomeViewModel .filter { it.collectionType in supportedCollectionTypes } .map { view -> val title = - view - ?.name - ?.let { - "Recently Added in $it" - } + view.name?.let { "Recently Added in $it" } val request = GetLatestMediaRequest( fields = DefaultItemFields, @@ -205,6 +201,7 @@ class HomeViewModel parentId = view.id, groupItems = true, limit = limit, + isPlayed = null, // Server will handle user's preference ) val latest = api.userLibraryApi diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index ce5c98f1..e98b72bd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -66,11 +66,14 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.supportedCollectionTypes import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -85,15 +88,19 @@ class NavDrawerViewModel val selectedIndex = MutableLiveData(-1) init { - viewModelScope.launch(ExceptionHandler()) { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler(true)) { val userViews = api.userViewsApi .getUserViews() .content.items - libraries.value = + val libraries = userViews .filter { it.collectionType in supportedCollectionTypes } .map { BaseItem.from(it, api) } + Timber.d("Got ${userViews.size} user views filtered to ${libraries.size}") + withContext(Dispatchers.Main) { + this@NavDrawerViewModel.libraries.value = libraries + } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index baef5461..5fc2f3c6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -264,22 +264,22 @@ fun PlaybackOverlay( } } } - AnimatedVisibility( - showDebugInfo && controllerViewState.controlsVisible, - modifier = - Modifier - .align(Alignment.TopStart), - ) { - currentPlayback?.tracks?.letNotEmpty { - PlaybackTrackInfo( - trackSupport = it, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(16.dp) - .background(AppColors.TransparentBlack50), - ) - } + } + AnimatedVisibility( + showDebugInfo && controllerViewState.controlsVisible, + modifier = + Modifier + .align(Alignment.TopStart), + ) { + currentPlayback?.tracks?.letNotEmpty { + PlaybackTrackInfo( + trackSupport = it, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(16.dp) + .background(AppColors.TransparentBlack50), + ) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt index 980a6d14..2704fdda 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt @@ -2,8 +2,10 @@ package com.github.damontecres.dolphin.util import android.widget.Toast import com.github.damontecres.dolphin.DolphinApplication +import com.github.damontecres.dolphin.ui.showToast import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.runBlocking import timber.log.Timber import kotlin.coroutines.CoroutineContext @@ -29,12 +31,13 @@ class ExceptionHandler( Timber.e(exception, "Exception in coroutine") if (autoToast) { - Toast - .makeText( + runBlocking { + showToast( DolphinApplication.instance, "Error: ${exception.message}", Toast.LENGTH_LONG, - ).show() + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt index 0201d794..33073572 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt @@ -1,7 +1,10 @@ package com.github.damontecres.dolphin.util import android.os.Build +import com.github.damontecres.dolphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -48,3 +51,10 @@ val BaseItemDto.seasonEpisodePadded: String? } else { null } + +fun formatSubtitleLang(mediaStreams: List?): String? = + mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() } + ?.mapNotNull { it.language } + ?.distinct() + ?.joinToString(", ") { languageName(it) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt index d258b783..21d90128 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt @@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.util import android.widget.Toast import androidx.lifecycle.MutableLiveData import com.github.damontecres.dolphin.DolphinApplication +import com.github.damontecres.dolphin.ui.showToast import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -39,15 +40,13 @@ class LoadingExceptionHandler( exception = exception, ) } - } - - if (autoToast) { - Toast - .makeText( + if (autoToast) { + showToast( DolphinApplication.instance, "Error: ${exception.message}", Toast.LENGTH_LONG, - ).show() + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt index 59fdcfc2..b40f0281 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt @@ -43,7 +43,7 @@ data class TrackSupport( if (split.size > 1) split[1] else codecs } } - val language = languageName(context, format.language) + val language = languageName(format.language) "$language ($type)" } } @@ -135,16 +135,14 @@ fun checkForSupport(tracks: Tracks): List = } } -fun languageName( - context: Context, - code: String?, -) = if (code != null && code != "00") { - try { - Locale(code).displayLanguage - } catch (ex: Exception) { - Timber.w(ex, "Error in locale for '$code'") - code.uppercase() +fun languageName(code: String?): String = + if (code != null) { + try { + Locale(code).displayLanguage + } catch (ex: Exception) { + Timber.w(ex, "Error in locale for '$code'") + code.uppercase() + } + } else { + "Unknown" } -} else { - "Unknown" -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt index acb97680..7fc35045 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt @@ -19,6 +19,7 @@ import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.hilt.StandardOkHttpClient import com.github.damontecres.dolphin.ui.findActivity import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.showToast import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -87,22 +88,20 @@ class UpdateChecker "Skipping update notification, threshold is $lastUpdateCheckThreshold", ) } else { - Toast - .makeText( - context, - "Update available: $installedVersion => ${latestRelease.version}!", - Toast.LENGTH_LONG, - ).show() + showToast( + context, + "Update available: $installedVersion => ${latestRelease.version}!", + Toast.LENGTH_LONG, + ) } } else { Timber.v("No update available for $installedVersion") if (showNegativeToast) { - Toast - .makeText( - context, - "No updates available, $installedVersion is the latest!", - Toast.LENGTH_LONG, - ).show() + showToast( + context, + "No updates available, $installedVersion is the latest!", + Toast.LENGTH_LONG, + ) } } }