Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9c859b1
feat: add continue watching flow and scoped watch progress tracking
Copilot Apr 16, 2026
389e44d
chore: refine progress reporting implementation details
Copilot Apr 16, 2026
a26c853
chore: remove duplicate watch-progress throttle guard
Copilot Apr 16, 2026
aeb17fc
fix: keep resumable items even when runtime metadata is missing
Copilot Apr 16, 2026
19d1168
chore: plan user selection with name and avatar in API key settings
Copilot Apr 16, 2026
578b010
chore: plan next-up card for player screen
Copilot Apr 16, 2026
d0489e4
chore: plan hide continue watching when no explicit user selected wit…
Copilot Apr 16, 2026
2e55601
feat: hide continue watching when API key login has no explicit user …
Copilot Apr 16, 2026
8349b0e
feat: move user selection above change server in settings connection …
Copilot Apr 16, 2026
d15f4c9
chore: plan user selection improvements
Copilot Apr 16, 2026
8f25cd1
fix: remove subtitle from Active User setting item
Copilot Apr 16, 2026
ffb90d0
fix: remove settings_active_user_subtitle string resource
Copilot Apr 16, 2026
9b30e21
User selection: always visible, no implicit default, per-auth-type sw…
Copilot Apr 16, 2026
46ab1da
fix: resolve Visibility icon references and premature SwitchAccountSh…
Copilot Apr 16, 2026
1816cae
plan: revert long-press tracking; add Save Watch Progress toggle in p…
Copilot Apr 16, 2026
d13787a
feat: replace long-press progress options with Save Watch Progress to…
Copilot Apr 16, 2026
8267a35
feat: carry watch-progress toggle state back through SeriesScreen on …
Copilot Apr 17, 2026
0fb64f2
fix: reset tracking on manual back-nav; mark completion instead of sa…
Copilot Apr 17, 2026
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 @@ -10,6 +10,7 @@ import org.introskipper.segmenteditor.data.model.AuthenticationResult
import org.introskipper.segmenteditor.data.model.ItemsResponse
import org.introskipper.segmenteditor.data.model.MediaItem
import org.introskipper.segmenteditor.data.model.PublicSystemInfo
import org.introskipper.segmenteditor.data.model.UpdateUserItemDataDto
import org.introskipper.segmenteditor.data.model.Segment
import org.introskipper.segmenteditor.data.model.SegmentCreateRequest
import org.introskipper.segmenteditor.data.model.SegmentResponse
Expand Down Expand Up @@ -127,4 +128,19 @@ interface JellyfinApi {
@Path("userId") userId: String,
@Header("Authorization") authHeader: String
): Response<ItemsResponse>

@POST("UserItems/{itemId}/UserData")
suspend fun updateUserItemData(
@Path("itemId") itemId: String,
@Query("userId") userId: String? = null,
@Body data: UpdateUserItemDataDto,
@Header("Authorization") authHeader: String
): Response<Unit>

@POST("UserPlayedItems/{itemId}")
suspend fun markItemPlayed(
@Path("itemId") itemId: String,
@Query("userId") userId: String? = null,
@Header("Authorization") authHeader: String
): Response<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import org.introskipper.segmenteditor.data.model.Segment
import org.introskipper.segmenteditor.data.model.SegmentCreateRequest
import org.introskipper.segmenteditor.data.model.SegmentResponse
import org.introskipper.segmenteditor.data.model.ServerInfo
import org.introskipper.segmenteditor.data.model.UpdateUserItemDataDto
import org.introskipper.segmenteditor.data.model.User
import org.introskipper.segmenteditor.storage.SecurePreferences
import retrofit2.Response
Expand Down Expand Up @@ -208,9 +209,41 @@ class JellyfinApiService(private val securePreferences: SecurePreferences) {
ensureInitialized()
return api!!.getLibraries(userId, getApiKey())
}

suspend fun updateUserItemData(
itemId: String,
data: UpdateUserItemDataDto,
userId: String? = null
): Response<Unit> {
ensureInitialized()
return api!!.updateUserItemData(itemId = itemId, userId = userId, data = data, authHeader = getApiKey())
}

suspend fun markItemPlayed(itemId: String, userId: String? = null): Response<Unit> {
ensureInitialized()
return api!!.markItemPlayed(itemId = itemId, userId = userId, authHeader = getApiKey())
}

// ========== Image URL Builders ==========


/**
* Gets the primary image URL for a user avatar.
* Returns null when the user has no image tag.
*/
fun getUserImageUrl(userId: String, imageTag: String?): String? {
if (imageTag.isNullOrBlank()) return null
val baseUrl = currentBaseUrl ?: return null
val uri = baseUrl.toUri()
.buildUpon()
.appendPath("Users")
.appendPath(userId)
.appendPath("Images")
.appendPath("Primary")
.appendQueryParameter("tag", imageTag)
.build()
return uri.toString()
}

/**
* Gets the primary image URL for an item
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Intro-Skipper Devs <intro-skipper.org>
* SPDX-License-Identifier: GPL-3.0-only
*/

package org.introskipper.segmenteditor.data.model

import com.google.gson.annotations.SerializedName

data class UpdateUserItemDataDto(
@SerializedName("PlaybackPositionTicks")
val playbackPositionTicks: Long? = null,
@SerializedName("PlayedPercentage")
val playedPercentage: Double? = null,
@SerializedName("Played")
val played: Boolean? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package org.introskipper.segmenteditor.data.repository
import org.introskipper.segmenteditor.api.JellyfinApiService
import org.introskipper.segmenteditor.data.model.ItemsResponse
import org.introskipper.segmenteditor.data.model.MediaItem
import org.introskipper.segmenteditor.data.model.UpdateUserItemDataDto
import retrofit2.Response

/**
Expand Down Expand Up @@ -179,7 +180,10 @@ class MediaRepository(
limit = limit,
sortBy = "DatePlayed",
sortOrder = "Descending",
fields = JellyfinApiService.DETAIL_FIELDS
fields = listOf(
"Overview", "PrimaryImageAspectRatio", "ImageTags", "RunTimeTicks", "ProviderIds",
"UserData", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"
)
)
}

Expand All @@ -198,6 +202,18 @@ class MediaRepository(
fields = JellyfinApiService.EPISODE_FIELDS
)
}

suspend fun updateUserItemData(
itemId: String,
userId: String,
data: UpdateUserItemDataDto
): Response<Unit> {
return apiService.updateUserItemData(itemId = itemId, data = data, userId = userId)
}

suspend fun markItemPlayed(itemId: String, userId: String): Response<Unit> {
return apiService.markItemPlayed(itemId = itemId, userId = userId)
}

/**
* Gets item as a Result, wrapping exceptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ class SecurePreferences(context: Context) {
return sharedPreferences.getString(KEY_DEVICE_ID, null)
}

// ========== Auth Mode ==========

fun saveIsApiKeyLogin(isApiKey: Boolean) {
sharedPreferences.edit { putBoolean(KEY_IS_API_KEY_LOGIN, isApiKey) }
}

fun getIsApiKeyLogin(): Boolean {
return sharedPreferences.getBoolean(KEY_IS_API_KEY_LOGIN, false)
}

fun saveHasExplicitUserSelection(hasSelection: Boolean) {
sharedPreferences.edit { putBoolean(KEY_HAS_EXPLICIT_USER_SELECTION, hasSelection) }
}

fun getHasExplicitUserSelection(): Boolean {
return sharedPreferences.getBoolean(KEY_HAS_EXPLICIT_USER_SELECTION, false)
}

// ========== Playback Settings ==========

fun setAutoPlayNextEpisode(enabled: Boolean) {
Expand Down Expand Up @@ -199,6 +217,8 @@ class SecurePreferences(context: Context) {
private const val KEY_USER_ID = "user_id"
private const val KEY_USERNAME = "username"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_IS_API_KEY_LOGIN = "is_api_key_login"
private const val KEY_HAS_EXPLICIT_USER_SELECTION = "has_explicit_user_selection"

// Playback keys
private const val KEY_AUTO_PLAY_NEXT = "auto_play_next"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ fun AppNavigation(
onLibraryClick = { libraryId, collectionType ->
navController.navigate("${Screen.Home.route}/$libraryId?type=$collectionType")
},
onContinueWatchingClick = { itemId ->
navController.navigate(Screen.Player.createRoute(itemId, trackProgress = true))
},
onSettingsClick = {
navController.navigate(Screen.Settings.route)
},
Expand All @@ -111,6 +114,9 @@ fun AppNavigation(
onLibraryClick = { libraryId, collectionType ->
navController.navigate("${Screen.Home.route}/$libraryId?type=$collectionType")
},
onContinueWatchingClick = { itemId ->
navController.navigate(Screen.Player.createRoute(itemId, trackProgress = true))
},
onSettingsClick = {
navController.navigate(Screen.Settings.route)
},
Expand Down Expand Up @@ -148,11 +154,18 @@ fun AppNavigation(
}

composable(
route = "${Screen.Player.route}/{itemId}",
arguments = listOf(navArgument("itemId") { type = NavType.StringType })
route = "${Screen.Player.route}/{itemId}?trackProgress={trackProgress}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType },
navArgument("trackProgress") {
type = NavType.BoolType
defaultValue = false
}
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
PlayerScreen(itemId = itemId, navController = navController)
val trackProgress = backStackEntry.arguments?.getBoolean("trackProgress") ?: false
PlayerScreen(itemId = itemId, navController = navController, trackProgressEnabled = trackProgress)
}

composable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ sealed class Screen(val route: String) {
object Main : Screen("main")
object Home : Screen("home")
object Player : Screen("player") {
fun createRoute(itemId: String) = "player/$itemId"
fun createRoute(itemId: String, trackProgress: Boolean = false) =
if (trackProgress) "player/$itemId?trackProgress=true" else "player/$itemId"
}
object Series : Screen("series")
object Album : Screen("album")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
Expand Down Expand Up @@ -65,6 +66,7 @@ import org.introskipper.segmenteditor.ui.component.translatedString
import org.introskipper.segmenteditor.ui.component.WavyCircularProgressIndicator
import org.introskipper.segmenteditor.ui.state.ThemeState
import org.introskipper.segmenteditor.ui.util.getDominantColor
import org.introskipper.segmenteditor.ui.viewmodel.ContinueWatchingItem
import org.introskipper.segmenteditor.ui.viewmodel.Library
import org.introskipper.segmenteditor.ui.viewmodel.LibraryEvent
import org.introskipper.segmenteditor.ui.viewmodel.LibraryUiState
Expand All @@ -74,6 +76,7 @@ import org.introskipper.segmenteditor.ui.viewmodel.LibraryViewModel
@Composable
fun LibraryScreen(
onLibraryClick: (String, String?) -> Unit,
onContinueWatchingClick: (String) -> Unit = {},
onSettingsClick: () -> Unit = {},
viewModel: LibraryViewModel = hiltViewModel(),
themeState: ThemeState
Expand Down Expand Up @@ -176,6 +179,36 @@ fun LibraryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (state.continueWatching.isNotEmpty()) {
item {
Text(
text = translatedString(R.string.library_continue_watching),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
)
}
items(state.continueWatching.count()) { item ->
val mediaItem = state.continueWatching[item]
ContinueWatchingCard(
item = mediaItem,
getPrimaryImageUrl = { itemId, imageTag -> viewModel.getPrimaryImageUrl(itemId, imageTag) },
onClick = { onContinueWatchingClick(mediaItem.id) }
)
}
item { Spacer(modifier = Modifier.height(12.dp)) }
}

item {
Text(
text = translatedString(R.string.library_select),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
)
}
items(state.libraries.count()) { item ->
val library = state.libraries[item]
val isSharing = state.isSharingLibraryId == library.id
Expand Down Expand Up @@ -226,6 +259,68 @@ fun LibraryScreen(
}
}

@Composable
private fun ContinueWatchingCard(
item: ContinueWatchingItem,
getPrimaryImageUrl: (String, String) -> String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val imageUrl = item.primaryImageTag?.let { getPrimaryImageUrl(item.id, it) }
val subtitle = if (item.seasonNumber != null && item.episodeNumber != null) {
"${item.seriesName ?: item.name} • S${item.seasonNumber}E${item.episodeNumber}"
} else {
item.seriesName ?: item.type ?: ""
}

Card(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.width(96.dp)
.height(56.dp),
contentScale = ContentScale.Crop
)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = item.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LinearProgressIndicator(
progress = { item.progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

@Composable
private fun LibraryCard(
library: Library,
Expand Down
Loading
Loading