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
222 changes: 222 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
storeFile=release.keystore
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyAlias=${{ secrets.KEY_ALIAS }}
keyPassword=${{ secrets.KEY_PASSWORD }}
EOF

- name: Build sideload APK
run: ./gradlew :app:assembleSideloadRelease --stacktrace

- name: Build Play Store AAB
run: ./gradlew :app:bundlePlayRelease --stacktrace

- name: Extract version from tag
id: version
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$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<<EOFNOTES" >> "$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<<EOFNOTES" >> "$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 <<EOFBODY
## ARVIO ${TAG} Released!

${{ steps.changelog.outputs.notes }}

---

📥 **[Download APK](${RELEASE_URL})** | Also available on Google Play Store
EOFBODY
)"

# Submit post
curl -s -X POST https://oauth.reddit.com/api/submit \
-H "Authorization: bearer ${TOKEN}" \
-A "ARVIO-Release-Bot/1.0" \
-d "sr=${REDDIT_SUBREDDIT}" \
-d "kind=self" \
--data-urlencode "title=ARVIO ${TAG} - Update Released" \
--data-urlencode "text=${BODY}"
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ All notable changes to this project are documented in this file.
### Added
- (Nothing yet)

## [1.9.7] - 2026-04-01

### Added
- Trakt watchlist two-way sync: items added in ARVIO sync to Trakt and vice versa
- Clearlogo overlays on watchlist cards
- Clearlogo repositioned to bottom-left corner on all landscape cards for a cleaner look
- Watchlist preloads on app startup for instant display
- Home screen categories cached for instant re-navigation
- Automated release pipeline (GitHub Actions: build, GitHub Release, Play Store, Discord)

### Improved
- Player buttons: focused state now shows white filled circle with black icon
- Subtitle system: only the selected subtitle is loaded instead of all 30+, significantly faster playback startup
- Non-English subtitles (OpenSubtitles) now work reliably across all languages
- Poster cards 10% larger on home screen with proper row spacing
- Watchlist poster cards sized consistently with home screen
- Watchlist grid columns optimized for poster layout (6-8 columns)
- Home screen card titles removed (clearlogo on card is sufficient)
- Real-time cloud sync fixed: WebSocket now authenticates with user JWT for instant cross-device updates
- Addon input modal: D-pad navigation fully working after typing/pasting URL
- Addon save reliability: fixed race condition where addon showed as added but wasn't persisted

### Fixed
- Continue Watching showing episodes/seasons that don't exist (e.g., S2E1 for a 1-season show)
- Watchlist page: left D-pad navigation to sidebar now works correctly
- Watchlist/sidebar: selecting Home/TV/Settings no longer accidentally opens a details page
- Subtitle rebuild loop removed: no more flickering or infinite re-preparing during playback

## [1.9.2] - 2026-03-19

### Added
Expand Down
13 changes: 10 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ android {

defaultConfig {
applicationId = "com.arvio.tv"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Fire TV devices can be as low as Android 7.1 (API 25) or lower depending on model/OS.
// Lower minSdk to maximize compatibility and avoid "There was a problem parsing the package".
minSdk = 21
targetSdk = 35
versionCode = 227
versionName = "1.9.6"
versionCode = 228
versionName = "1.9.7"
buildConfigField("String", "GITHUB_OWNER", "\"ProdigyV21\"")
buildConfigField("String", "GITHUB_REPO", "\"ARVIO\"")

Expand Down Expand Up @@ -154,7 +155,11 @@ android {

packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/LICENSE*",
"/META-INF/NOTICE*",
)
}
jniLibs {
useLegacyPackaging = false // Required for 16KB page size support
Expand Down Expand Up @@ -286,7 +291,9 @@ dependencies {
// Android Instrumented Testing
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:core-ktx:1.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
androidTestImplementation("io.mockk:mockk-android:1.13.8")
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.arflix.tv.data.repository.AuthRepository
import com.arflix.tv.data.repository.AuthState
import com.arflix.tv.data.repository.CloudSyncRepository
import com.arflix.tv.data.repository.RealtimeSyncManager
import com.arflix.tv.data.repository.WatchlistRepository
import com.arflix.tv.data.repository.ProfileManager
import com.arflix.tv.util.AppLogger
import com.arflix.tv.util.CrashlyticsProvider
Expand Down Expand Up @@ -51,6 +52,8 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact
lateinit var cloudSyncRepository: CloudSyncRepository
@Inject
lateinit var realtimeSyncManager: RealtimeSyncManager
@Inject
lateinit var watchlistRepository: WatchlistRepository

override fun onCreate() {
super.onCreate()
Expand All @@ -67,6 +70,8 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact

appScope.launch {
runCatching { profileManager.initialize() }
// Preload watchlist cache in background for instant display
runCatching { watchlistRepository.getWatchlistItems() }
if (!authRepository.getCurrentUserId().isNullOrBlank()) {
// Pull cloud state shortly after startup for faster cross-device sync.
delay(3_000L)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ class MediaRepository @Inject constructor(
private data class CacheEntry<T>(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<Category> = emptyList()
private set
@Volatile private var homeCategoriesFetchedAt = 0L
private val HOME_CATEGORIES_CACHE_MS = 120_000L // 2 minutes

private val detailsCache = mutableMapOf<String, CacheEntry<MediaItem>>()
private val castCache = mutableMapOf<String, CacheEntry<List<CastMember>>>()
private val similarCache = mutableMapOf<String, CacheEntry<List<MediaItem>>>()
Expand Down Expand Up @@ -192,6 +198,18 @@ class MediaRepository @Inject constructor(
* - Provider categories: wider recency window to keep full rows populated
*/
suspend fun getHomeCategories(): List<Category> = 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<Category> = coroutineScope {
suspend fun fetchUpTo40(fetchPage: suspend (Int) -> TmdbListResponse): List<TmdbMediaItem> {
val first = runCatching { fetchPage(1) }.getOrNull() ?: return emptyList()
val firstItems = first.results
Expand Down
Loading
Loading