diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..11d77a541 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,222 @@ +name: Release Pipeline + +# Trigger: push a version tag like v1.9.7 +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + # ─── Build ──────────────────────────────────────────────────────── + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Decode keystore + run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore + + - name: Create keystore.properties + run: | + cat > keystore.properties <> "$GITHUB_OUTPUT" + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: sideload-apk + path: app/build/outputs/apk/sideload/release/*.apk + + - name: Upload AAB artifact + uses: actions/upload-artifact@v4 + with: + name: play-aab + path: app/build/outputs/bundle/playRelease/*.aab + + outputs: + tag: ${{ steps.version.outputs.tag }} + + # ─── GitHub Release ─────────────────────────────────────────────── + github-release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: sideload-apk + path: artifacts/ + + - name: Extract changelog for this version + id: changelog + run: | + TAG="${{ needs.build.outputs.tag }}" + VERSION="${TAG#v}" + # Extract section between this version header and the next version header + NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md) + if [ -z "$NOTES" ]; then + NOTES="Release ${TAG}" + fi + # Write to file for gh release + echo "$NOTES" > release_notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ needs.build.outputs.tag }}" \ + artifacts/*.apk \ + --title "${{ needs.build.outputs.tag }}" \ + --notes-file release_notes.md + + # ─── Play Store Upload ──────────────────────────────────────────── + play-store: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: play-aab + path: artifacts/ + + - name: Upload to Play Store (internal track) + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.arvio.tv + releaseFiles: artifacts/*.aab + track: internal + status: completed + # Change track to 'production' when ready for full release + # track: production + + # ─── Discord Announcement ───────────────────────────────────────── + discord: + needs: [build, github-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract changelog + id: changelog + run: | + TAG="${{ needs.build.outputs.tag }}" + VERSION="${TAG#v}" + NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md) + if [ -z "$NOTES" ]; then + NOTES="New release available!" + fi + # Truncate to 1800 chars for Discord embed limit + NOTES="${NOTES:0:1800}" + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOFNOTES" >> "$GITHUB_OUTPUT" + + - name: Post to Discord + env: + WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + TAG="${{ needs.build.outputs.tag }}" + RELEASE_URL="https://github.com/ProdigyV21/ARVIO/releases/tag/${TAG}" + + # Build JSON payload with embed + jq -n \ + --arg title "🚀 ARVIO ${TAG} Released!" \ + --arg desc "${{ steps.changelog.outputs.notes }}" \ + --arg url "$RELEASE_URL" \ + '{ + embeds: [{ + title: $title, + description: $desc, + url: $url, + color: 5814783, + footer: { text: "Download the APK from GitHub or update via Play Store" } + }] + }' > payload.json + + curl -f -H "Content-Type: application/json" \ + -d @payload.json \ + "$WEBHOOK_URL" + + # ─── Reddit Post ────────────────────────────────────────────────── + reddit: + needs: [build, github-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract changelog + id: changelog + run: | + TAG="${{ needs.build.outputs.tag }}" + VERSION="${TAG#v}" + NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md) + if [ -z "$NOTES" ]; then + NOTES="New release available!" + fi + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOFNOTES" >> "$GITHUB_OUTPUT" + + - name: Post to Reddit + env: + REDDIT_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }} + REDDIT_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }} + REDDIT_USERNAME: ${{ secrets.REDDIT_USERNAME }} + REDDIT_PASSWORD: ${{ secrets.REDDIT_PASSWORD }} + REDDIT_SUBREDDIT: ${{ secrets.REDDIT_SUBREDDIT }} + run: | + TAG="${{ needs.build.outputs.tag }}" + RELEASE_URL="https://github.com/ProdigyV21/ARVIO/releases/tag/${TAG}" + + # Get OAuth token + TOKEN=$(curl -s -X POST https://www.reddit.com/api/v1/access_token \ + -u "${REDDIT_CLIENT_ID}:${REDDIT_CLIENT_SECRET}" \ + -d "grant_type=password&username=${REDDIT_USERNAME}&password=${REDDIT_PASSWORD}" \ + -A "ARVIO-Release-Bot/1.0" | jq -r '.access_token') + + # Build post body + BODY="$(cat <(val data: T, val timestamp: Long) private val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes + // Home categories cache - survives ViewModel recreation + @Volatile var cachedHomeCategories: List = emptyList() + private set + @Volatile private var homeCategoriesFetchedAt = 0L + private val HOME_CATEGORIES_CACHE_MS = 120_000L // 2 minutes + private val detailsCache = mutableMapOf>() private val castCache = mutableMapOf>>() private val similarCache = mutableMapOf>>() @@ -192,6 +198,18 @@ class MediaRepository @Inject constructor( * - Provider categories: wider recency window to keep full rows populated */ suspend fun getHomeCategories(): List = coroutineScope { + // Return cached categories if still fresh + val now = System.currentTimeMillis() + if (cachedHomeCategories.isNotEmpty() && now - homeCategoriesFetchedAt < HOME_CATEGORIES_CACHE_MS) { + return@coroutineScope cachedHomeCategories + } + val result = getHomeCategoriesInternal() + cachedHomeCategories = result + homeCategoriesFetchedAt = System.currentTimeMillis() + result + } + + private suspend fun getHomeCategoriesInternal(): List = coroutineScope { suspend fun fetchUpTo40(fetchPage: suspend (Int) -> TmdbListResponse): List { val first = runCatching { fetchPage(1) }.getOrNull() ?: return emptyList() val firstItems = first.results diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt index 96f563810..15cc8b1a0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt @@ -49,6 +49,10 @@ class RealtimeSyncManager @Inject constructor( @Volatile private var lastPushTimestamp = 0L + // User JWT for authenticated Realtime subscriptions + @Volatile + private var currentAccessToken: String? = null + fun markPush() { lastPushTimestamp = System.currentTimeMillis() } @@ -89,6 +93,22 @@ class RealtimeSyncManager @Inject constructor( return } + // Fetch user access token for authenticated Realtime subscriptions + // Without the JWT, Supabase RLS blocks the postgres_changes subscription + scope.launch { + val accessToken = authRepository.getAccessToken() + if (accessToken.isNullOrBlank()) { + Log.w(TAG, "No access token, skipping WebSocket connection") + scheduleReconnect() + return@launch + } + connectWebSocketWithToken(userId, accessToken) + } + } + + private fun connectWebSocketWithToken(userId: String, accessToken: String) { + if (!isRunning.get()) return + val supabaseUrl = Constants.SUPABASE_URL .replace("https://", "wss://") .replace("http://", "ws://") @@ -101,6 +121,9 @@ class RealtimeSyncManager @Inject constructor( val request = Request.Builder().url(wsUrl).build() + // Store the token so joinChannel can include it + currentAccessToken = accessToken + webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { Log.i(TAG, "WebSocket connected") @@ -133,6 +156,7 @@ class RealtimeSyncManager @Inject constructor( private fun joinChannel(ws: WebSocket, userId: String) { // Subscribe to postgres_changes on account_sync_state table filtered by user_id + // access_token is required for Supabase RLS to authenticate the subscription val joinMsg = JSONObject().apply { put("topic", "realtime:account_sync") put("event", "phx_join") @@ -147,6 +171,7 @@ class RealtimeSyncManager @Inject constructor( }) }) }) + currentAccessToken?.let { put("access_token", it) } }) put("ref", msgRef.getAndIncrement().toString()) } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index d394139bd..91d29f386 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -1211,16 +1211,26 @@ class TraktRepository @Inject constructor( 1 } - if (isIncomplete && effectiveCompleted >= 1) { + // Validate next episode actually exists: + // Check that the season has aired episodes in Trakt's progress data + val nextEpValid = if (nextEp != null && progress.seasons != null) { + val seasonData = progress.seasons.find { it.number == nextEp.season } + seasonData != null && seasonData.aired > 0 + } else { + nextEp != null + } + val validNextEp = if (nextEpValid) nextEp else null + + if (isIncomplete && effectiveCompleted >= 1 && validNextEp != null) { ContinueWatchingCandidate( item = ContinueWatchingItem( id = tmdbId, title = show.show.title, mediaType = MediaType.TV, progress = syntheticProgress, - season = nextEp?.season, - episode = nextEp?.number, - episodeTitle = nextEp?.title, + season = validNextEp.season, + episode = validNextEp.number, + episodeTitle = validNextEp.title, year = show.show.year?.toString() ?: "" ), lastActivityAt = show.lastWatchedAt ?: "" @@ -1360,7 +1370,15 @@ class TraktRepository @Inject constructor( ) } else { val details = tmdbApi.getTvDetails(item.id, Constants.TMDB_API_KEY) - item.copy( + // Validate next episode season exists on TMDB + // Trakt may return S2E1 for a show that only has 1 season + val validatedItem = if (item.season != null && item.season > details.numberOfSeasons) { + // Next episode season doesn't exist — skip this item + null + } else { + item + } + validatedItem?.copy( backdropPath = details.backdropPath?.let { "${Constants.BACKDROP_BASE_LARGE}$it" }, posterPath = details.posterPath?.let { "${Constants.IMAGE_BASE}$it" }, overview = details.overview ?: "", @@ -1376,12 +1394,14 @@ class TraktRepository @Inject constructor( } } - val hydratedItems = hydrationTasks.awaitAll() + val hydratedItems = hydrationTasks.awaitAll().filterNotNull() // Ensure we never lose items due to TMDB validation failures - prioritize local status // If hydration returned empty despite having candidates, fall back to local data if (hydratedItems.isEmpty() && topCandidates.isNotEmpty()) { // Map candidates back to items without TMDB enrichment + // Filter out items with null season/episode (already validated at candidate creation) val fallbackItems = topCandidates.map { it.item } + .filter { it.mediaType != MediaType.TV || (it.season != null && it.episode != null) } cachedContinueWatching = fallbackItems lastContinueWatchingFetch = System.currentTimeMillis() persistContinueWatchingCache(fallbackItems) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt index c9bbc5353..69831926a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt @@ -74,6 +74,7 @@ fun MediaCard( isLandscape: Boolean = true, logoImageUrl: String? = null, showProgress: Boolean = false, + showTitle: Boolean = true, titleMaxLines: Int = 1, subtitleMaxLines: Int = 1, isFocusedOverride: Boolean = false, @@ -183,17 +184,18 @@ fun MediaCard( .background(overlayBrush) ) - // Official logo/art overlay centered on landscape cards. + // Official logo/art overlay in bottom-left corner of landscape cards. if (isLandscape && logoRequest != null) { AsyncImage( model = logoRequest, contentDescription = "${item.title} logo", contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(0.62f) - .height(56.dp) - .padding(horizontal = 8.dp, vertical = 6.dp) + .align(Alignment.BottomStart) + .fillMaxWidth(0.52f) + .height(48.dp) + .padding(start = 10.dp, bottom = 18.dp) ) } @@ -246,37 +248,39 @@ fun MediaCard( } } - Spacer(modifier = Modifier.height(ArvioSkin.spacing.x2)) + if (showTitle) { + Spacer(modifier = Modifier.height(ArvioSkin.spacing.x2)) - Text( - text = item.title, - style = ArvioSkin.typography.cardTitle, - color = if (visualFocused) { - ArvioSkin.colors.textPrimary - } else { - ArvioSkin.colors.textPrimary.copy(alpha = 0.85f) - }, - maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = item.title, + style = ArvioSkin.typography.cardTitle, + color = if (visualFocused) { + ArvioSkin.colors.textPrimary + } else { + ArvioSkin.colors.textPrimary.copy(alpha = 0.85f) + }, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) - // Arctic Fuse 2 style: Show media type with genre-like format - val subtitle = remember(item.subtitle, item.mediaType) { - item.subtitle.ifBlank { - when (item.mediaType) { - MediaType.TV -> "Drama / TV Series" - MediaType.MOVIE -> "Action / Movie" - else -> "Media" + // Arctic Fuse 2 style: Show media type with genre-like format + val subtitle = remember(item.subtitle, item.mediaType) { + item.subtitle.ifBlank { + when (item.mediaType) { + MediaType.TV -> "Drama / TV Series" + MediaType.MOVIE -> "Action / Movie" + else -> "Media" + } } } + Text( + text = subtitle, + style = ArvioSkin.typography.caption, + color = ArvioSkin.colors.textMuted.copy(alpha = 0.85f), + maxLines = subtitleMaxLines, + overflow = TextOverflow.Ellipsis, + ) } - Text( - text = subtitle, - style = ArvioSkin.typography.caption, - color = ArvioSkin.colors.textMuted.copy(alpha = 0.85f), - maxLines = subtitleMaxLines, - overflow = TextOverflow.Ellipsis, - ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 98a9f5191..0d601f1b9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -827,8 +827,12 @@ class DetailsViewModel @Inject constructor( if (newInWatchlist) { // Pass the full MediaItem so it appears instantly in watchlist watchlistRepository.addToWatchlist(currentMediaType, currentMediaId, currentItem) + // Also add to Trakt if connected + runCatching { traktRepository.addToWatchlist(currentMediaType, currentMediaId) } } else { watchlistRepository.removeFromWatchlist(currentMediaType, currentMediaId) + // Also remove from Trakt if connected + runCatching { traktRepository.removeFromWatchlist(currentMediaType, currentMediaId) } } runCatching { cloudSyncRepository.pushToCloud() } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index af8159d47..d5f18b5c0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -2017,6 +2017,7 @@ private fun MobileHomeRowsLayer( isLandscape = !usePosterCards, logoImageUrl = cardLogoUrl, showProgress = false, + showTitle = false, isFocusedOverride = false, enableSystemFocus = false, onFocused = {}, @@ -2033,6 +2034,7 @@ private fun MobileHomeRowsLayer( isLandscape = !usePosterCards, logoImageUrl = cardLogoUrl, showProgress = isContinueWatching, + showTitle = false, isFocusedOverride = false, enableSystemFocus = false, onFocused = {}, @@ -2117,10 +2119,11 @@ private fun TvHomeRowsLayer( animationSpec = tween(durationMillis = 300), label = "homeRowAlpha" ).value + val rowHeight = if (usePosterCards) 240.dp else 190.dp Box( modifier = Modifier .fillMaxWidth() - .height(220.dp) + .height(rowHeight) .clipToBounds() .graphicsLayer { alpha = rowAlpha } ) { @@ -2299,7 +2302,7 @@ private fun ContentRow( val configuration = LocalConfiguration.current val density = LocalDensity.current val isContinueWatching = category.id == "continue_watching" - val itemWidth = if (usePosterCards) 114.dp else 210.dp + val itemWidth = if (usePosterCards) 125.dp else 210.dp val itemSpacing = 14.dp val availableWidthDp = configuration.screenWidthDp.dp - 56.dp - 12.dp val fallbackItemsPerPage = remember(configuration, density, itemWidth, itemSpacing) { @@ -2487,9 +2490,9 @@ private fun ContentRow( val itemIsFocused = currentIsCurrentRow && index == currentFocusedIndex if (isRanked) { // RANKED ITEM: Number + Card - val rankedCardWidth = if (usePosterCards) 90.dp else 140.dp - val rankedBoxWidth = if (usePosterCards) 150.dp else 210.dp - val rankedBoxHeight = if (usePosterCards) 160.dp else 140.dp + val rankedCardWidth = if (usePosterCards) 100.dp else 140.dp + val rankedBoxWidth = if (usePosterCards) 165.dp else 210.dp + val rankedBoxHeight = if (usePosterCards) 176.dp else 140.dp val rankFontSize = if (usePosterCards) 80.sp else 100.sp Box( modifier = Modifier @@ -2520,6 +2523,7 @@ private fun ContentRow( isLandscape = !usePosterCards, logoImageUrl = cardLogoUrl, showProgress = false, + showTitle = false, isFocusedOverride = itemIsFocused, enableSystemFocus = false, onFocused = { onItemFocused(item, index) }, @@ -2536,6 +2540,7 @@ private fun ContentRow( isLandscape = !usePosterCards, logoImageUrl = cardLogoUrl, showProgress = isContinueWatching, + showTitle = false, isFocusedOverride = itemIsFocused, enableSystemFocus = false, onFocused = { onItemFocused(item, index) }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 104b23ed8..fdfe35b07 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -823,7 +823,14 @@ fun PlayerScreen( rebufferRecoverAttempted = false longRebufferCount = 0 - val subtitleConfigs = buildExternalSubtitleConfigurations(uiState.subtitles) + // Only add the selected subtitle to ExoPlayer (not all 30+). + // Loading all external subs slows down preparation and causes non-UTF8 subs to fail. + val selectedSub = uiState.selectedSubtitle + val subtitleConfigs = if (selectedSub != null && !selectedSub.isEmbedded) { + buildExternalSubtitleConfigurations(listOf(selectedSub)) + } else { + emptyList() + } val mediaItemBuilder = MediaItem.Builder().setUri(Uri.parse(url)) if (subtitleConfigs.isNotEmpty()) { mediaItemBuilder.setSubtitleConfigurations(subtitleConfigs) @@ -887,20 +894,61 @@ fun PlayerScreen( } // When new external subtitles arrive after initial load, rebuild the MediaItem once. - // Uses a flag to prevent infinite rebuild loops (onTracksChanged → size change → rebuild → onTracksChanged). + // Subtitle rebuild removed: we now load only the selected subtitle on-demand. + // When user switches subtitles, the LaunchedEffect below rebuilds the MediaItem with the new sub. var subtitleRebuildDone by remember { mutableStateOf(false) } var initialSubtitleCount by remember { mutableIntStateOf(-1) } LaunchedEffect(uiState.subtitles.size) { - if (playerReleased || subtitleRebuildDone) return@LaunchedEffect + if (playerReleased) return@LaunchedEffect val newCount = uiState.subtitles.size if (initialSubtitleCount < 0) { initialSubtitleCount = newCount; return@LaunchedEffect } + // No longer rebuild with all subs - they're loaded individually on selection + initialSubtitleCount = newCount + } + // Reset rebuild flag when stream changes + LaunchedEffect(uiState.selectedStreamUrl) { subtitleRebuildDone = false; initialSubtitleCount = -1 } + + // When subtitle selection changes, rebuild MediaItem with just the selected subtitle. + // This avoids loading all 30+ subtitle files and fixes non-English encoding issues. + LaunchedEffect(uiState.selectedSubtitle, uiState.subtitleSelectionNonce) { + if (playerReleased) return@LaunchedEffect + val subtitle = uiState.selectedSubtitle val url = uiState.selectedStreamUrl ?: return@LaunchedEffect - // Only rebuild once when external subtitles arrive (count increases after initial) - if (newCount > initialSubtitleCount && exoPlayer.playbackState != Player.STATE_IDLE) { - subtitleRebuildDone = true + + if (subtitle == null) { + // Disable all text tracks + exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .build() + return@LaunchedEffect + } + + if (subtitle.isEmbedded && subtitle.groupIndex != null && subtitle.trackIndex != null) { + // For embedded subs, just select the track directly + val groups = exoPlayer.currentTracks.groups + val params = exoPlayer.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + if (subtitle.groupIndex in groups.indices && + groups[subtitle.groupIndex].type == C.TRACK_TYPE_TEXT) { + params.setOverrideForType( + androidx.media3.common.TrackSelectionOverride( + groups[subtitle.groupIndex].mediaTrackGroup, + subtitle.trackIndex + ) + ) + } + exoPlayer.trackSelectionParameters = params.build() + return@LaunchedEffect + } + + // External subtitle: rebuild MediaItem with just this one subtitle + if (subtitle.url.isNotBlank() && exoPlayer.playbackState != Player.STATE_IDLE) { val currentPosition = exoPlayer.currentPosition val wasPlaying = exoPlayer.isPlaying - val subtitleConfigs = buildExternalSubtitleConfigurations(uiState.subtitles) + val subtitleConfigs = buildExternalSubtitleConfigurations(listOf(subtitle)) val mediaItem = MediaItem.Builder() .setUri(Uri.parse(url)) .setSubtitleConfigurations(subtitleConfigs) @@ -908,54 +956,46 @@ fun PlayerScreen( exoPlayer.setMediaItem(mediaItem, currentPosition) exoPlayer.prepare() if (wasPlaying) exoPlayer.play() + + // Enable the subtitle track after rebuild + exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setPreferredTextLanguage(subtitle.lang) + .setSelectUndeterminedTextLanguage(true) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .build() } } - // Reset rebuild flag when stream changes - LaunchedEffect(uiState.selectedStreamUrl) { subtitleRebuildDone = false; initialSubtitleCount = -1 } - // Apply subtitle changes without reloading the media source. - LaunchedEffect(uiState.selectedSubtitle, uiState.subtitleSelectionNonce, uiState.subtitles) { + // Re-apply embedded subtitle selection when track list updates (e.g., after onTracksChanged) + LaunchedEffect(uiState.subtitles) { if (playerReleased) return@LaunchedEffect - val subtitle = uiState.selectedSubtitle - - val params = exoPlayer.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_TEXT) - - if (subtitle == null) { - exoPlayer.trackSelectionParameters = params - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - .build() - return@LaunchedEffect - } + val subtitle = uiState.selectedSubtitle ?: return@LaunchedEffect + if (!subtitle.isEmbedded) return@LaunchedEffect - val resolvedSubtitle = uiState.subtitles.firstOrNull { + // Find the resolved version with groupIndex/trackIndex from ExoPlayer + val resolved = uiState.subtitles.firstOrNull { it.id == subtitle.id && it.groupIndex != null && it.trackIndex != null - } ?: uiState.subtitles.firstOrNull { - subtitle.url.isNotBlank() && it.url == subtitle.url && it.groupIndex != null && it.trackIndex != null - } ?: subtitle + } ?: return@LaunchedEffect - val groupIndex = resolvedSubtitle.groupIndex - val trackIndex = resolvedSubtitle.trackIndex val groups = exoPlayer.currentTracks.groups - if (groupIndex != null && trackIndex != null && - groupIndex in groups.indices && - groups[groupIndex].type == C.TRACK_TYPE_TEXT + if (resolved.groupIndex != null && resolved.trackIndex != null && + resolved.groupIndex in groups.indices && + groups[resolved.groupIndex].type == C.TRACK_TYPE_TEXT ) { - params.setOverrideForType( - androidx.media3.common.TrackSelectionOverride( - groups[groupIndex].mediaTrackGroup, - trackIndex + exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setOverrideForType( + androidx.media3.common.TrackSelectionOverride( + groups[resolved.groupIndex].mediaTrackGroup, + resolved.trackIndex + ) ) - ) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .build() } - - exoPlayer.trackSelectionParameters = params - .setPreferredTextLanguage(subtitle.lang) - .setSelectUndeterminedTextLanguage(true) - .setIgnoredTextSelectionFlags(0) - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .build() } // Auto-hide controls and return focus to container @@ -2311,9 +2351,7 @@ private fun PlayerIconButton( onDownKey: () -> Unit = {} ) { var focused by remember { mutableStateOf(false) } - // Focused: enlarge icon and brighten it (glow effect via scale + tint) - val scale by animateFloatAsState(if (focused) 1.35f else 1f, label = "iconScale") - val iconAlpha by animateFloatAsState(if (focused) 1f else 0.6f, label = "iconAlpha") + val scale by animateFloatAsState(if (focused) 1.15f else 1f, label = "iconScale") Box( modifier = Modifier @@ -2334,13 +2372,17 @@ private fun PlayerIconButton( } else false } .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { onClick() } - .graphicsLayer { scaleX = scale; scaleY = scale }, + .graphicsLayer { scaleX = scale; scaleY = scale } + .background( + color = if (focused) Color.White else Color.Transparent, + shape = CircleShape + ), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = contentDescription, - tint = Color.White.copy(alpha = iconAlpha), + tint = if (focused) Color.Black else Color.White.copy(alpha = 0.6f), modifier = Modifier.size(iconSize) ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 9e5e0185b..86b1b166c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -3734,7 +3734,14 @@ private fun InputModal( val target = fields.firstOrNull() if (clipboardText != null && target != null) { target.onValueChange(clipboardText) + // Also update the EditText directly to keep in sync + editTextRefs.getOrNull(0)?.let { edit -> + edit.setText(clipboardText) + edit.clearFocus() + } } + // Ensure Compose focus stays on the modal for D-pad nav + modalFocusRequester.requestFocus() true } focusedIndex == fields.size + 1 -> { @@ -3857,11 +3864,47 @@ private fun InputModal( field.onValueChange(editable?.toString() ?: "") } + // Forward D-pad events to Compose navigation instead of letting EditText consume them + setOnKeyListener { v, keyCode, event -> + if (event.action == android.view.KeyEvent.ACTION_DOWN) { + when (keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> { + val imm = ctx.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + clearFocus() + focusedIndex = (index + 1).coerceAtMost(totalItems - 1) + modalFocusRequester.requestFocus() + true + } + android.view.KeyEvent.KEYCODE_DPAD_UP -> { + if (index > 0) { + val imm = ctx.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + clearFocus() + focusedIndex = index - 1 + modalFocusRequester.requestFocus() + } + true + } + android.view.KeyEvent.KEYCODE_BACK -> { + val imm = ctx.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + clearFocus() + modalFocusRequester.requestFocus() + true + } + else -> false + } + } else false + } + setOnEditorActionListener { _, actionId, _ -> if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) { val imm = ctx.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager imm?.hideSoftInputFromWindow(windowToken, 0) clearFocus() + // Return focus to Compose so D-pad navigation works + modalFocusRequester.requestFocus() true } else false } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index f449237b3..820cc2c46 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -877,6 +877,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { val result = streamRepository.addCustomAddon(url) result.onSuccess { addon -> + // Small delay to let DataStore flush the write before reading back + delay(150) val currentAddons = streamRepository.installedAddons.first() val importedCatalogs = addon.manifest?.catalogs?.size ?: 0 runCatching { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt index 8f8ec93a9..ec9db3764 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp @@ -83,20 +84,23 @@ fun WatchlistScreen( onBack: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() + val logoUrls by viewModel.logoUrls.collectAsState() val usePosterCards = com.arflix.tv.ui.components.rememberCardLayoutMode() == com.arflix.tv.ui.components.CardLayoutMode.POSTER val configuration = LocalConfiguration.current val isMobile = LocalDeviceType.current.isTouchDevice() - val gridColumns = if (isMobile) 2 else when { + val gridColumns = if (isMobile) 2 else if (usePosterCards) { + when { + configuration.screenWidthDp >= 2200 -> 8 + configuration.screenWidthDp >= 1600 -> 7 + else -> 6 + } + } else when { configuration.screenWidthDp >= 2200 -> 5 configuration.screenWidthDp >= 1600 -> 4 else -> 3 } val cardWidth = if (usePosterCards) { - if (isMobile) 142.dp else when (gridColumns) { - 5 -> 198.dp - 4 -> 210.dp - else -> 188.dp - } + if (isMobile) 124.dp else 125.dp } else if (isMobile) 160.dp else when (gridColumns) { 5 -> 240.dp 4 -> 250.dp @@ -155,20 +159,32 @@ fun WatchlistScreen( } } .focusable() - .onKeyEvent { event -> + .onPreviewKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { + // Helper: transition focus from grid to sidebar + fun moveToSidebar() { + isSidebarFocused = true + // Immediately steal focus from grid card to prevent card click on next Enter + runCatching { rootFocusRequester.requestFocus() } + } + when (event.key) { Key.Back, Key.Escape -> { if (isSidebarFocused) { onBack() } else { - isSidebarFocused = true + moveToSidebar() } true } Key.DirectionLeft -> { if (!isSidebarFocused) { - true + if (focusedGridIndex % gridColumns == 0) { + moveToSidebar() + true + } else { + false + } } else { if (sidebarFocusIndex > 0) { sidebarFocusIndex = (sidebarFocusIndex - 1).coerceIn(0, maxSidebarIndex) @@ -190,11 +206,9 @@ fun WatchlistScreen( if (isSidebarFocused) { true } else { - // When in grid and Up is pressed, allow native focus to handle it - // If at first visible item, transition to sidebar val firstVisibleIndex = gridState.firstVisibleItemIndex if (firstVisibleIndex == 0 && focusedGridIndex < gridColumns) { - isSidebarFocused = true + moveToSidebar() true } else { false @@ -212,10 +226,8 @@ fun WatchlistScreen( } true } else { - // When in grid and Down is pressed, allow native focus to handle it - // Only consume if we're at the last item (prevent getting stuck) if (focusedGridIndex >= uiState.items.size - 1) { - true // Consume to prevent focus loss + true } else { false } @@ -348,10 +360,12 @@ fun WatchlistScreen( } ) { itemsIndexed(uiState.items) { index, item -> + val logoUrl = logoUrls["${item.mediaType}_${item.id}"] MediaCard( item = item, width = cardWidth, isLandscape = !usePosterCards, + logoImageUrl = logoUrl, onFocused = { focusedGridIndex = index }, onClick = { onNavigateToDetails(item.mediaType, item.id) } ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index 98eb4041a..033022b49 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -3,7 +3,10 @@ package com.arflix.tv.ui.screens.watchlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.repository.CloudSyncRepository +import com.arflix.tv.data.repository.MediaRepository +import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.data.repository.WatchlistRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -28,17 +31,24 @@ data class WatchlistUiState( @HiltViewModel class WatchlistViewModel @Inject constructor( private val watchlistRepository: WatchlistRepository, - private val cloudSyncRepository: CloudSyncRepository + private val cloudSyncRepository: CloudSyncRepository, + private val traktRepository: TraktRepository, + private val mediaRepository: MediaRepository ) : ViewModel() { private val _uiState = MutableStateFlow(WatchlistUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _logoUrls = MutableStateFlow>(emptyMap()) + val logoUrls: StateFlow> = _logoUrls.asStateFlow() + init { // Show cached items instantly, then refresh in background loadWatchlistInstant() // Also observe the repository's StateFlow for live updates observeWatchlistChanges() + // Sync Trakt watchlist → local (merge any items added via Trakt) + syncTraktWatchlist() } private fun observeWatchlistChanges() { @@ -49,6 +59,22 @@ class WatchlistViewModel @Inject constructor( items = items, isLoading = false ) + fetchLogos(items) + } + } + } + } + + private fun fetchLogos(items: List) { + viewModelScope.launch { + val currentLogos = _logoUrls.value.toMutableMap() + for (item in items) { + val key = "${item.mediaType}_${item.id}" + if (key in currentLogos) continue + val url = runCatching { mediaRepository.getLogoUrl(item.mediaType, item.id) }.getOrNull() + if (url != null) { + currentLogos[key] = url + _logoUrls.value = currentLogos.toMap() } } } @@ -120,6 +146,8 @@ class WatchlistViewModel @Inject constructor( ) // Then sync to backend watchlistRepository.removeFromWatchlist(item.mediaType, item.id) + // Also remove from Trakt if connected + runCatching { traktRepository.removeFromWatchlist(item.mediaType, item.id) } runCatching { cloudSyncRepository.pushToCloud() } } catch (e: Exception) { _uiState.value = _uiState.value.copy( @@ -130,6 +158,37 @@ class WatchlistViewModel @Inject constructor( } } + /** + * Pull Trakt watchlist and merge new items into local watchlist. + * Items on Trakt but not local get added; local-only items are preserved. + */ + private fun syncTraktWatchlist() { + viewModelScope.launch { + try { + val traktItems = traktRepository.getWatchlist() + if (traktItems.isEmpty()) return@launch + + // Merge: add any Trakt items not already in local watchlist + var addedNew = false + for (item in traktItems) { + val inLocal = watchlistRepository.isInWatchlist(item.mediaType, item.id) + if (!inLocal) { + watchlistRepository.addToWatchlist(item.mediaType, item.id, item) + addedNew = true + } + } + + // Only refresh if we actually added new items (avoids clearing cache) + if (addedNew) { + val items = watchlistRepository.refreshWatchlistItems() + _uiState.value = _uiState.value.copy(items = items, isLoading = false) + } + } catch (_: Exception) { + // Trakt sync is best-effort, don't show errors + } + } + } + fun dismissToast() { _uiState.value = _uiState.value.copy(toastMessage = null) }