Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ data class PlayerState(
val error: PlayerError? = null,
val isFullscreen: Boolean = true,
val sessionId: String? = null,
val isControlsLocked: Boolean = false
val isControlsLocked: Boolean = false,
val isInPictureInPictureMode: Boolean = false
)

data class PlayerError(
Expand All @@ -43,6 +44,7 @@ sealed class PlayerEvent {
object ToggleControls : PlayerEvent()
object ToggleLock : PlayerEvent()
object ToggleFullscreen : PlayerEvent()
object EnterPictureInPicture : PlayerEvent()
data class LoadMedia(
val item: AfinityItem,
val mediaSourceId: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ object FieldSets {
*/
val CONTINUE_WATCHING = listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.TRICKPLAY
)
ItemFields.TRICKPLAY,
ItemFields.OVERVIEW
)

/**
* NEXT_UP - Next up episodes section (HomeScreen.kt, NextUpSection.kt)
Expand Down Expand Up @@ -192,7 +193,7 @@ object FieldSets {
)

/**
* EPISODE_LIST - Season episode listings (EpisodeListContent.kt, NextUpSection.kt)
* EPISODE_LIST - Season episode listings (NextUpSection.kt)
*
* Displays:
* - Episode thumbnail
Expand All @@ -209,7 +210,7 @@ object FieldSets {
)

/**
* SEASON_DETAIL - Season detail pages (EpisodeListContent.kt - SeasonDetailsSection)
* SEASON_DETAIL - Season detail pages (SeasonDetailsSection)
*
* Displays:
* - Season poster
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ interface JellyfinRepository {
): List<AfinityItem>

suspend fun getEpisodeToPlay(seriesId: UUID): AfinityEpisode?
suspend fun getEpisodeToPlayForSeason(seasonId: UUID, seriesId: UUID): AfinityEpisode?

suspend fun getMovies(
parentId: UUID? = null,
Expand Down Expand Up @@ -189,6 +190,7 @@ interface JellyfinRepository {
suspend fun getFavoriteMovies(): List<AfinityMovie>
suspend fun getFavoriteShows(): List<AfinityShow>
suspend fun getFavoriteEpisodes(): List<AfinityEpisode>
suspend fun getFavoriteSeasons(): List<AfinitySeason>

fun getLibrariesFlow(): Flow<List<AfinityCollection>>
fun getLatestMediaFlow(parentId: UUID? = null): Flow<List<AfinityItem>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ interface PreferencesRepository {
suspend fun getDynamicColors(): Boolean
fun getDynamicColorsFlow(): Flow<Boolean>

suspend fun setPipGestureEnabled(enabled: Boolean)
suspend fun getPipGestureEnabled(): Boolean
fun getPipGestureEnabledFlow(): Flow<Boolean>

suspend fun setGridLayout(enabled: Boolean)
suspend fun getGridLayout(): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,21 +550,16 @@ class JellyfinRepositoryImpl @Inject constructor(
return try {
Timber.d("Getting episode to play for series: $seriesId")

val continueWatchingEpisodes = getContinueWatching(limit = 50)
val seriesContinueWatching = continueWatchingEpisodes
.filterIsInstance<AfinityEpisode>()
.firstOrNull { it.seriesId == seriesId && it.playbackPositionTicks > 0 && !it.played }

if (seriesContinueWatching != null) {
Timber.d("Found continue watching episode: ${seriesContinueWatching.name}")
return getFullEpisodeDetails(seriesContinueWatching.id)
}

try {
val nextUpEpisodes = mediaRepository.getNextUp(seriesId, limit = 1)
val nextUpEpisodes = mediaRepository.getNextUp(
seriesId = seriesId,
limit = 1,
fields = FieldSets.ITEM_DETAIL,
enableResumable = false
)
if (nextUpEpisodes.isNotEmpty()) {
Timber.d("Found next up episode: ${nextUpEpisodes.first().name}")
return getFullEpisodeDetails(nextUpEpisodes.first().id)
return nextUpEpisodes.first()
}
} catch (e: Exception) {
Timber.w(e, "NextUp API failed, falling back to manual logic")
Expand All @@ -578,27 +573,32 @@ class JellyfinRepositoryImpl @Inject constructor(

val sortedSeasons = seasons.sortedBy { it.indexNumber ?: 0 }

val allEpisodes = mutableListOf<AfinityEpisode>()
for (season in sortedSeasons) {
val episodes = getEpisodes(season.id, seriesId)
if (episodes.isEmpty()) continue
val episodes = getEpisodes(season.id, seriesId, fields = FieldSets.ITEM_DETAIL)
allEpisodes.addAll(episodes)
}

if (allEpisodes.isEmpty()) {
Timber.w("No episodes found for series: $seriesId")
return null
}

val sortedEpisodes = episodes.sortedBy { it.indexNumber ?: 0 }
val sortedEpisodes = allEpisodes.sortedWith(
compareBy<AfinityEpisode> { it.parentIndexNumber ?: 0 }
.thenBy { it.indexNumber ?: 0 }
)

val nextEpisode = sortedEpisodes.firstOrNull { !it.played }
if (nextEpisode != null) {
Timber.d("Found next unwatched episode: ${nextEpisode.name}")
return getFullEpisodeDetails(nextEpisode.id)
}
val nextEpisode = sortedEpisodes.firstOrNull { !it.played }
if (nextEpisode != null) {
Timber.d("Found next unwatched episode: ${nextEpisode.name}")
return nextEpisode
}

val firstSeason = sortedSeasons.firstOrNull()
if (firstSeason != null) {
val firstSeasonEpisodes = getEpisodes(firstSeason.id, seriesId)
val firstEpisode = firstSeasonEpisodes.sortedBy { it.indexNumber ?: 0 }.firstOrNull()
if (firstEpisode != null) {
Timber.d("All episodes watched, returning first episode: ${firstEpisode.name}")
return getFullEpisodeDetails(firstEpisode.id)
}
val firstEpisode = sortedEpisodes.firstOrNull()
if (firstEpisode != null) {
Timber.d("All episodes watched, returning first episode: ${firstEpisode.name}")
return firstEpisode
}

Timber.w("No episode found to play for series: $seriesId")
Expand Down Expand Up @@ -631,7 +631,55 @@ class JellyfinRepositoryImpl @Inject constructor(
return mediaRepository.getFavoriteEpisodes()
}

suspend fun getPlayableItemForSeries(seriesId: UUID): AfinityItem? {
return getEpisodeToPlay(seriesId)
override suspend fun getFavoriteSeasons(): List<AfinitySeason> {
return mediaRepository.getFavoriteSeasons()
}

override suspend fun getEpisodeToPlayForSeason(seasonId: UUID, seriesId: UUID): AfinityEpisode? {
return try {
Timber.d("Getting episode to play for season: $seasonId")

try {
val nextUpEpisodes = mediaRepository.getNextUp(
seriesId = seriesId,
limit = 10,
fields = FieldSets.ITEM_DETAIL,
enableResumable = false
)
val nextUpForSeason = nextUpEpisodes.firstOrNull { it.seasonId == seasonId }
if (nextUpForSeason != null) {
Timber.d("Found next up episode for season: ${nextUpForSeason.name}")
return nextUpForSeason
}
} catch (e: Exception) {
Timber.w(e, "NextUp API failed for season")
}

val episodes = getEpisodes(seasonId, seriesId, fields = FieldSets.ITEM_DETAIL)
if (episodes.isEmpty()) {
Timber.w("No episodes found for season: $seasonId")
return null
}

val sortedEpisodes = episodes.sortedBy { it.indexNumber ?: 0 }

val nextEpisode = sortedEpisodes.firstOrNull { !it.played }
if (nextEpisode != null) {
Timber.d("Found next unwatched episode in season: ${nextEpisode.name}")
return nextEpisode
}

val firstEpisode = sortedEpisodes.firstOrNull()
if (firstEpisode != null) {
Timber.d("All episodes watched in season, returning first episode: ${firstEpisode.name}")
return firstEpisode
}

Timber.w("No episode found to play for season: $seasonId")
null
} catch (e: Exception) {
Timber.e(e, "Failed to determine episode to play for season: $seasonId")
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class PreferencesRepositoryImpl @Inject constructor(
val SKIP_OUTRO_ENABLED = booleanPreferencesKey("skip_outro_enabled")
val USE_EXO_PLAYER = booleanPreferencesKey("use_exo_player")
val THEME_MODE = stringPreferencesKey("theme_mode")
val PIP_GESTURE_ENABLED = booleanPreferencesKey("pip_gesture_enabled")

val DYNAMIC_COLORS = booleanPreferencesKey("dynamic_colors")
val GRID_LAYOUT = booleanPreferencesKey("grid_layout")
Expand Down Expand Up @@ -384,4 +385,20 @@ class PreferencesRepositoryImpl @Inject constructor(
preferences[Keys.USE_EXO_PLAYER] = value
}
}

override suspend fun setPipGestureEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[Keys.PIP_GESTURE_ENABLED] = enabled
}
}

override suspend fun getPipGestureEnabled(): Boolean {
return dataStore.data.first()[Keys.PIP_GESTURE_ENABLED] ?: false
}

override fun getPipGestureEnabledFlow(): Flow<Boolean> {
return dataStore.data.map { preferences ->
preferences[Keys.PIP_GESTURE_ENABLED] ?: false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,32 @@ class JellyfinMediaRepository @Inject constructor(
}
}

override suspend fun getFavoriteSeasons(
fields: List<ItemFields>?
): List<AfinitySeason> = withContext(Dispatchers.IO) {
return@withContext try {
val userId = getCurrentUserId() ?: return@withContext emptyList()
val itemsApi = ItemsApi(apiClient)

val response = itemsApi.getItems(
userId = userId,
includeItemTypes = listOf(BaseItemKind.SEASON),
isFavorite = true,
recursive = true,
fields = fields ?: FieldSets.MEDIA_ITEM_CARDS,
enableImages = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.SORT_NAME)
)
response.content?.items?.mapNotNull { baseItem ->
baseItem.toAfinitySeason(getBaseUrl())
} ?: emptyList()
} catch (e: Exception) {
Timber.e(e, "Failed to get favorite seasons")
emptyList()
}
}

override suspend fun getNextUp(
seriesId: UUID?,
limit: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ interface MediaRepository {
fields: List<ItemFields>? = null
): List<AfinityEpisode>

suspend fun getFavoriteSeasons(
fields: List<ItemFields>? = null
): List<AfinitySeason>

suspend fun getGenres(
parentId: UUID? = null,
limit: Int? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.makd.afinity.data.repository.watchlist

import com.makd.afinity.data.models.media.AfinityEpisode
import com.makd.afinity.data.models.media.AfinityMovie
import com.makd.afinity.data.models.media.AfinitySeason
import com.makd.afinity.data.models.media.AfinityShow
import kotlinx.coroutines.flow.Flow
import java.util.UUID
Expand All @@ -20,6 +21,8 @@ interface WatchlistRepository {

suspend fun getWatchlistShows(): List<AfinityShow>

suspend fun getWatchlistSeasons(): List<AfinitySeason>

suspend fun getWatchlistEpisodes(): List<AfinityEpisode>

suspend fun getWatchlistCount(): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.makd.afinity.data.database.dao.WatchlistDao
import com.makd.afinity.data.database.entities.WatchlistItemEntity
import com.makd.afinity.data.models.media.AfinityEpisode
import com.makd.afinity.data.models.media.AfinityMovie
import com.makd.afinity.data.models.media.AfinitySeason
import com.makd.afinity.data.models.media.AfinityShow
import com.makd.afinity.data.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -109,6 +110,30 @@ class WatchlistRepositoryImpl @Inject constructor(
}
}

override suspend fun getWatchlistSeasons(): List<AfinitySeason> {
return withContext(Dispatchers.IO) {
try {

val watchlistItems = watchlistDao.getWatchlistItemsByType("SEASON")

val seasons = watchlistItems.mapNotNull { entity ->
try {
val item = jellyfinRepository.getItemById(entity.itemId)
item as? AfinitySeason
} catch (e: Exception) {
Timber.e(e, "Failed to load season ${entity.itemId} from watchlist")
null
}
}
Timber.d("Loaded ${seasons.size} seasons from watchlist")
seasons
} catch (e: Exception) {
Timber.e(e, "Failed to load watchlist seasons")
emptyList()
}
}
}

override suspend fun getWatchlistEpisodes(): List<AfinityEpisode> {
return withContext(Dispatchers.IO) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ enum class Destination(
return "item_detail/$itemId"
}
fun createEpisodeListRoute(seasonId: String, seasonName: String): String {
return "episodes/$seasonId/${seasonName.replace("/", "%2F")}"
return createItemDetailRoute(seasonId)
}
fun createSearchRoute(): String {
return SEARCH_ROUTE
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.makd.afinity.R
import com.makd.afinity.data.repository.watchlist.WatchlistRepository
import com.makd.afinity.ui.episode.EpisodeListScreen
import com.makd.afinity.ui.favorites.FavoritesScreen
import com.makd.afinity.ui.home.HomeScreen
import com.makd.afinity.ui.item.ItemDetailScreen
Expand Down Expand Up @@ -275,6 +274,30 @@ fun MainNavigation(
navArgument("seasonId") { type = NavType.StringType },
navArgument("seasonName") { type = NavType.StringType }
)
) { backStackEntry ->
ItemDetailScreen(
onPlayClick = { item, selection ->
if (selection != null) {
com.makd.afinity.ui.player.PlayerLauncher.launch(
context = navController.context,
itemId = item.id,
mediaSourceId = selection.mediaSourceId,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
startPositionMs = selection.startPositionMs
)
}
},
navController = navController
)
}

/*composable(
route = Destination.EPISODE_LIST_ROUTE,
arguments = listOf(
navArgument("seasonId") { type = NavType.StringType },
navArgument("seasonName") { type = NavType.StringType }
)
) {
EpisodeListScreen(
onBackClick = {
Expand All @@ -286,7 +309,7 @@ fun MainNavigation(
navController = navController,
modifier = Modifier.fillMaxSize()
)
}
}*/

composable(
route = Destination.PERSON_ROUTE,
Expand Down
Loading