feat: add movies stats screen and improve provider sync

- Add MoviesScreen and corresponding navigation
- Implement movie data aggregation in ViewingDao and repository
- Enhance NetflixParser to support multiple JSON payload shapes
- Apply custom app theme and update launcher icons
- Add back navigation handling to ProviderSyncScreen
This commit is contained in:
Tronax 2026-08-04 17:46:57 +02:00
parent dce3b2258b
commit f55ff411c6
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
20 changed files with 1086 additions and 143 deletions

View file

@ -12,13 +12,13 @@
android:label="BingeStats"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoTitleBar"
android:theme="@style/Theme.BingeStats"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.NoTitleBar">
android:theme="@style/Theme.BingeStats">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Tv
@ -29,6 +30,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.bingestats.app.ui.screens.AnalyticsScreen
import com.bingestats.app.ui.screens.DashboardScreen
import com.bingestats.app.ui.screens.MoviesScreen
import com.bingestats.app.ui.screens.ProviderSyncScreen
import com.bingestats.app.ui.screens.SeriesStatsScreen
import com.bingestats.app.ui.screens.SettingsScreen
@ -42,6 +44,7 @@ import com.bingestats.app.ui.viewmodel.MainViewModel
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
object Dashboard : Screen("dashboard", "Home", Icons.Default.Home)
object Series : Screen("series", "Serien", Icons.Default.Tv)
object Movies : Screen("movies", "Filme", Icons.Default.Movie)
object Analytics : Screen("analytics", "Analytics", Icons.Default.Analytics)
object Sync : Screen("sync", "Sync", Icons.Default.Sync)
object Settings : Screen("settings", "Settings", Icons.Default.Settings)
@ -52,7 +55,7 @@ class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate()
super.onCreate(savedInstanceState)
setContent {
BingeStatsTheme {
MainAppStructure(viewModel)
@ -70,6 +73,7 @@ fun MainAppStructure(viewModel: MainViewModel) {
val screens = listOf(
Screen.Dashboard,
Screen.Series,
Screen.Movies,
Screen.Analytics,
Screen.Sync,
Screen.Settings
@ -123,6 +127,9 @@ fun MainAppStructure(viewModel: MainViewModel) {
composable(Screen.Series.route) {
SeriesStatsScreen(viewModel = viewModel)
}
composable(Screen.Movies.route) {
MoviesScreen(viewModel = viewModel)
}
composable(Screen.Analytics.route) {
AnalyticsScreen(viewModel = viewModel)
}

View file

@ -16,6 +16,15 @@ data class ShowAggregation(
val posterPath: String?
)
data class MovieAggregation(
val showTitle: String,
val provider: String,
val watchCount: Int,
val totalMinutes: Int,
val lastWatchedDate: Long,
val posterPath: String?
)
data class MonthlyAggregation(
val yearMonth: String,
val totalMinutes: Int
@ -48,18 +57,35 @@ interface ViewingDao {
@Query("SELECT COUNT(*) FROM viewing_items")
fun getTotalItemsCount(): Flow<Int>
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items")
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items WHERE contentType != 'MOVIE'")
fun getUniqueShowsCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType = 'MOVIE'")
fun getMoviesCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType != 'MOVIE'")
fun getSeriesEpisodesCount(): Flow<Int>
@Query("""
SELECT showTitle, provider, COUNT(*) as episodeCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
FROM viewing_items
WHERE contentType != 'MOVIE'
GROUP BY showTitle
ORDER BY totalMinutes DESC
LIMIT :limit
""")
fun getTopShows(limit: Int = 20): Flow<List<ShowAggregation>>
@Query("""
SELECT showTitle, provider, COUNT(*) as watchCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
FROM viewing_items
WHERE contentType = 'MOVIE'
GROUP BY showTitle
ORDER BY totalMinutes DESC
LIMIT :limit
""")
fun getTopMovies(limit: Int = 20): Flow<List<MovieAggregation>>
@Query("""
SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes
FROM viewing_items

View file

@ -12,6 +12,18 @@ data class ShowSummary(
get() = totalMinutes / 60f
}
data class MovieSummary(
val showTitle: String,
val provider: StreamingProvider,
val watchCount: Int,
val totalMinutes: Int,
val lastWatchedDate: Long,
val posterPath: String? = null
) {
val totalHours: Float
get() = totalMinutes / 60f
}
data class MonthlyStat(
val yearMonth: String, // e.g. "2024-05"
val monthLabel: String, // e.g. "Mai 2024"
@ -39,6 +51,7 @@ data class WatchStats(
val moviesCount: Int = 0,
val uniqueShowsCount: Int = 0,
val topShows: List<ShowSummary> = emptyList(),
val topMovies: List<MovieSummary> = emptyList(),
val monthlyStats: List<MonthlyStat> = emptyList(),
val dayOfWeekStats: List<DayOfWeekStat> = emptyList(),
val providerStats: List<ProviderStat> = emptyList()

View file

@ -55,34 +55,80 @@ object NetflixParser {
}
/**
* Parse Netflix JSON API viewing activity payload (intercepted during WebView browsing).
* Parse JSON viewing activity payload (intercepted during WebView browsing).
* Accepts three shapes:
* 1. The Netflix Shakti API object: { "viewedItems": [ ... ] }
* 2. A flat JSON array: [ { ... }, { ... } ]
* 3. Any object exposing a "viewedItems" or "items" array.
* Each entry may use Netflix fields (title/seriesTitle/episodeTitle/date/duration)
* or the simpler Prime-style fields (title/episodeTitle/time/duration).
*
* @param provider the provider id stamped onto every parsed item.
*/
fun parseJsonApiPayload(jsonText: String, profileName: String = "Main Profile"): List<ViewingItem> {
fun parseJsonApiPayload(
jsonText: String,
profileName: String = "Main Profile",
provider: String = StreamingProvider.NETFLIX.id
): List<ViewingItem> {
val items = mutableListOf<ViewingItem>()
try {
val gson = Gson()
val jsonObject = gson.fromJson(jsonText, JsonObject::class.java)
val viewingData = jsonObject.getAsJsonArray("viewedItems") ?: return items
val jsonElement = gson.fromJson(jsonText, com.google.gson.JsonElement::class.java) ?: return items
val viewingData: JsonArray = when {
jsonElement.isJsonArray -> jsonElement.asJsonArray
jsonElement.isJsonObject -> {
val obj = jsonElement.asJsonObject
obj.getAsJsonArray("viewedItems")
?: obj.getAsJsonArray("items")
?: obj.getAsJsonArray("history")
?: return items
}
else -> return items
}
for (i in 0 until viewingData.size()) {
val item = viewingData[i].asJsonObject
val title = item.get("title")?.asString ?: item.get("seriesTitle")?.asString ?: continue
val dateEpoch = item.get("date")?.asLong ?: System.currentTimeMillis()
val parsedTitle = TitleParser.parseNetflixTitle(title)
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
val item = viewingData[i].takeIf { it.isJsonObject }?.asJsonObject ?: continue
// Series episodes carry seriesTitle + episodeTitle; movies carry only title.
val seriesTitle = item.get("seriesTitle")?.takeIf { !it.isJsonNull }?.asString
val episodeTitle = item.get("episodeTitle")?.takeIf { !it.isJsonNull }?.asString
val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString
?: seriesTitle
?: continue
// Build a raw title string the TitleParser understands.
val rawTitle = when {
seriesTitle != null && episodeTitle != null -> "$seriesTitle: $episodeTitle"
else -> title
}
val parsedTitle = TitleParser.parseNetflixTitle(rawTitle)
// Date: "date" (ms epoch) for Netflix, "time" (ISO string or epoch) for Prime.
val dateEpoch = when {
item.has("date") && !item.get("date").isJsonNull -> item.get("date").asLong
item.has("time") && !item.get("time").isJsonNull -> parseTimeToEpoch(item.get("time").asString)
else -> System.currentTimeMillis()
}
val (_, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
// Duration in minutes: Netflix/Prime provide it in ms.
val durationMs = item.get("duration")?.takeIf { !it.isJsonNull }?.asLong
val durationMinutes = (durationMs?.let { (it / 60000L).toInt() }?.takeIf { d -> d > 0 })
?: if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105
items.add(
ViewingItem(
provider = StreamingProvider.NETFLIX.id,
rawTitle = title,
provider = provider,
rawTitle = rawTitle,
showTitle = parsedTitle.showTitle,
seasonTitle = parsedTitle.seasonTitle,
episodeTitle = parsedTitle.episodeTitle,
contentType = parsedTitle.contentType,
watchDate = dateEpoch,
dateFormatted = dateFormatted,
durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105,
durationMinutes = durationMinutes,
profileName = profileName
)
)
@ -93,6 +139,38 @@ object NetflixParser {
return items
}
/**
* Best-effort parse of a time value to epoch millis.
* Accepts a numeric epoch (seconds or millis) or an ISO-8601 datetime string.
*/
private fun parseTimeToEpoch(timeStr: String): Long {
val trimmed = timeStr.trim()
// Pure number → epoch seconds (typical) or millis.
return trimmed.toLongOrNull()?.let { num ->
// Heuristic: values < year 3000 in seconds (~325 billion) treat as seconds.
if (num < 325_000_000_000L) num * 1000L else num
} ?: run {
// ISO-8601 string, e.g. "2025-07-15 21:04"
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd"
)
for (format in formats) {
try {
val sdf = java.text.SimpleDateFormat(format, java.util.Locale.getDefault())
sdf.timeZone = java.util.TimeZone.getDefault()
return sdf.parse(trimmed)?.time ?: continue
} catch (_: Exception) {
// try next format
}
}
System.currentTimeMillis()
}
}
private fun parseCsvLine(line: String): List<String> {
val result = mutableListOf<String>()
var inQuotes = false

View file

@ -0,0 +1,123 @@
package com.bingestats.app.data.parser
/**
* Provider-specific JavaScript snippets injected into the in-app WebView to extract
* viewing history as a clean JSON string. The result is fed to
* [NetflixParser.parseJsonApiPayload].
*
* Why JS extraction instead of parsing the rendered DOM:
* - Netflix's page is a React SPA with hashed, unstable CSS class names.
* The reliable source is the internal Shakti API, reachable in-page because the
* WebView session already holds the NetflixId cookies.
* - Prime Video embeds history as inline JSON in <script type="text/template"> tags
* and lazy-loads more via fetch.
*/
object WebSyncScripts {
/**
* Netflix: read BUILD_IDENTIFIER + authURL from the page's global context, then
* page through /api/shakti/<build>/viewingactivity collecting viewedItems.
* Returns a JSON array string (or "[]" on failure).
*/
val netflix: String = """
(function() {
try {
var ctx = window.netflix && window.netflix.reactContext;
if (!ctx) return JSON.stringify([]);
var serverDefs = ctx.models && ctx.models.serverDefs && ctx.models.serverDefs.data;
if (!serverDefs || !serverDefs.BUILD_IDENTIFIER) return JSON.stringify([]);
var buildId = serverDefs.BUILD_IDENTIFIER;
var authURL = "";
try {
authURL = ctx.models.memberContext.data.userInfo.data.authURL
|| ctx.models.userInfo.data.authURL || "";
} catch (e) {}
var collected = [];
var pageSize = 100;
var pg = 0;
var total = -1;
// Synchronous loop over pages. fetch() is async, so we chain via a
// recursive helper and resolve the outer Promise.
return new Promise(function(resolve) {
function loadPage(n) {
var url = '/api/shakti/' + buildId + '/viewingactivity?pg=' + n + '&pgsize=' + pageSize;
if (authURL) url += '&authURL=' + encodeURIComponent(authURL);
fetch(url, { credentials: 'include' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data) { resolve(JSON.stringify(collected)); return; }
var items = data.viewedItems || [];
collected = collected.concat(items);
if (total < 0) total = data.vhSize || 0;
pg++;
// Stop when a page is empty or we've read everything.
if (items.length === 0 || (total > 0 && collected.length >= total) || pg > 200) {
resolve(JSON.stringify(collected));
} else {
loadPage(pg);
}
})
.catch(function() { resolve(JSON.stringify(collected)); });
}
loadPage(0);
});
} catch (e) {
return JSON.stringify([]);
}
})()
""".trimIndent()
/**
* Prime Video: parse the inline JSON embedded in <script type="text/template"> tags.
* The structure is props -> widgets[] -> content.content.titles[] -> date sections,
* each section has .titles[] with {title:{text}, time, children:[...] }.
* Returns a JSON array string of {title, episodeTitle, time, duration}.
*/
val prime: String = """
(function() {
try {
var results = [];
var scripts = document.querySelectorAll('script[type="text/template"]');
for (var i = 0; i < scripts.length; i++) {
var raw = scripts[i].textContent.trim();
if (!raw || raw.charAt(0) !== '{') continue;
var obj;
try { obj = JSON.parse(raw); } catch (e) { continue; }
var widgets = (obj && obj.props) ? obj.props : [];
if (!Array.isArray(widgets)) widgets = [widgets];
widgets.forEach(function(w) {
try {
var titles = w && w.content && w.content.content && w.content.content.titles;
if (!Array.isArray(titles)) return;
titles.forEach(function(section) {
var items = section.titles || [];
items.forEach(function(t) {
if (!t || !t.title || !t.title.text) return;
results.push({
title: t.title.text,
time: t.time || null,
duration: t.duration || null
});
(t.children || []).forEach(function(c) {
if (!c || !c.title || !c.title.text) return;
results.push({
seriesTitle: t.title.text,
episodeTitle: c.title.text,
time: c.time || t.time || null,
duration: c.duration || null
});
});
});
});
} catch (e) {}
});
}
return JSON.stringify(results);
} catch (e) {
return JSON.stringify([]);
}
})()
""".trimIndent()
}

View file

@ -1,7 +1,12 @@
package com.bingestats.app.data.repository
import com.bingestats.app.data.db.MovieAggregation
import com.bingestats.app.data.db.MonthlyAggregation
import com.bingestats.app.data.db.ProviderAggregation
import com.bingestats.app.data.db.ShowAggregation
import com.bingestats.app.data.db.ViewingDatabase
import com.bingestats.app.data.model.DayOfWeekStat
import com.bingestats.app.data.model.MovieSummary
import com.bingestats.app.data.model.MonthlyStat
import com.bingestats.app.data.model.ProviderStat
import com.bingestats.app.data.model.ShowSummary
@ -30,10 +35,26 @@ class StatsRepository(private val db: ViewingDatabase) {
viewingDao.getTotalWatchTimeMinutes(),
viewingDao.getTotalItemsCount(),
viewingDao.getUniqueShowsCount(),
viewingDao.getMoviesCount(),
viewingDao.getSeriesEpisodesCount(),
viewingDao.getTopShows(20),
viewingDao.getTopMovies(20),
viewingDao.getMonthlyStats(),
viewingDao.getProviderStats()
) { totalMinutes, itemCount, uniqueShows, topShowsAgg, monthlyAgg, providerAgg ->
) { values ->
val totalMinutes = values[0] as Long?
val itemCount = values[1] as Int
val uniqueShows = values[2] as Int
val moviesCount = values[3] as Int
val seriesEpisodesCount = values[4] as Int
@Suppress("UNCHECKED_CAST")
val topShowsAgg = values[5] as List<ShowAggregation>
@Suppress("UNCHECKED_CAST")
val topMoviesAgg = values[6] as List<MovieAggregation>
@Suppress("UNCHECKED_CAST")
val monthlyAgg = values[7] as List<MonthlyAggregation>
@Suppress("UNCHECKED_CAST")
val providerAgg = values[8] as List<ProviderAggregation>
val topShows = topShowsAgg.map { agg ->
ShowSummary(
@ -46,6 +67,17 @@ class StatsRepository(private val db: ViewingDatabase) {
)
}
val topMovies = topMoviesAgg.map { agg ->
MovieSummary(
showTitle = agg.showTitle,
provider = StreamingProvider.fromId(agg.provider),
watchCount = agg.watchCount,
totalMinutes = agg.totalMinutes,
lastWatchedDate = agg.lastWatchedDate,
posterPath = agg.posterPath
)
}
val monthlyStats = monthlyAgg.map { agg ->
MonthlyStat(
yearMonth = agg.yearMonth,
@ -68,10 +100,11 @@ class StatsRepository(private val db: ViewingDatabase) {
WatchStats(
totalWatchTimeMinutes = totalMinutes ?: 0L,
totalItemsCount = itemCount,
seriesEpisodesCount = topShows.sumOf { it.episodeCount },
moviesCount = (itemCount - topShows.sumOf { it.episodeCount }).coerceAtLeast(0),
seriesEpisodesCount = seriesEpisodesCount,
moviesCount = moviesCount,
uniqueShowsCount = uniqueShows,
topShows = topShows,
topMovies = topMovies,
monthlyStats = monthlyStats,
providerStats = providerStats
)

View file

@ -0,0 +1,172 @@
package com.bingestats.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bingestats.app.data.model.MovieSummary
import com.bingestats.app.ui.components.ProviderBadge
import com.bingestats.app.ui.theme.CardBackgroundDark
import com.bingestats.app.ui.theme.GlassCardBorder
import com.bingestats.app.ui.theme.NetflixRed
import com.bingestats.app.ui.theme.ObsidianBlack
import com.bingestats.app.ui.theme.TextMuted
import com.bingestats.app.ui.theme.TextSecondary
import com.bingestats.app.ui.viewmodel.MainViewModel
@Composable
fun MoviesScreen(viewModel: MainViewModel) {
val stats by viewModel.watchStats.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(ObsidianBlack)
.padding(16.dp)
) {
Text(
text = "Top Filme Ranking",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = "Deine meistgesehenen Filme nach Gesamtzeit",
fontSize = 13.sp,
color = TextMuted
)
Spacer(modifier = Modifier.height(16.dp))
if (stats.topMovies.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Noch keine Filme erfasst.\nImportiere deinen Verlauf unter Sync.",
color = TextMuted,
fontSize = 14.sp
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(stats.topMovies) { index, movie ->
MovieRankItem(rank = index + 1, movie = movie)
}
}
}
}
}
@Composable
fun MovieRankItem(
rank: Int,
movie: MovieSummary
) {
Card(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Rank Number
Box(
modifier = Modifier
.width(36.dp)
.height(36.dp)
.background(
if (rank <= 3) NetflixRed.copy(alpha = 0.2f) else Color.Transparent,
RoundedCornerShape(8.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = "#$rank",
fontWeight = FontWeight.Black,
fontSize = 16.sp,
color = if (rank <= 3) NetflixRed else TextMuted
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = movie.showTitle,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
ProviderBadge(provider = movie.provider)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.Movie,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.width(16.dp).height(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (movie.watchCount == 1) "1x gesehen" else "${movie.watchCount}x gesehen",
fontSize = 12.sp,
color = TextSecondary
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = "${movie.totalMinutes / 60}h ${movie.totalMinutes % 60}m",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = NetflixRed
)
Text(
text = "${movie.totalMinutes} Minuten",
fontSize = 11.sp,
color = TextMuted
)
}
}
}
}

View file

@ -6,6 +6,7 @@ import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@ -26,7 +27,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@ -39,6 +39,7 @@ import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.bingestats.app.data.model.StreamingProvider
import com.bingestats.app.data.parser.WebSyncScripts
import com.bingestats.app.ui.components.ProviderBadge
import com.bingestats.app.ui.theme.CardBackgroundDark
import com.bingestats.app.ui.theme.GlassCardBorder
@ -232,9 +234,33 @@ fun WebViewSyncSection(
provider: StreamingProvider,
onExtractHistory: (String) -> Unit
) {
var webViewRef by remember { mutableStateOf<WebView?>(null) }
var currentUrl by remember { mutableStateOf(provider.defaultWebUrl) }
var isLoadingWeb by remember { mutableStateOf(false) }
// webSyncSupported == false -> show a graceful hint instead of a broken webview.
val webSyncSupported = provider == StreamingProvider.NETFLIX || provider == StreamingProvider.AMAZON_PRIME
var currentUrl by remember(provider) { mutableStateOf(provider.viewingHistoryUrl) }
var isLoadingWeb by remember { mutableStateOf(true) }
var isExtracting by remember { mutableStateOf(false) }
var statusMessage by remember { mutableStateOf<String?>(null) }
val webViewRef = remember { mutableStateOf<WebView?>(null) }
// Clean up the WebView when the composable leaves the composition. Without this the
// WebView can keep running JS/holding the screen, which previously blocked navigation.
DisposableEffect(Unit) {
onDispose {
webViewRef.value?.apply {
stopLoading()
removeAllViews()
destroy()
}
webViewRef.value = null
}
}
// Route the system back button: if the WebView has history, go back inside it;
// otherwise let the default (NavController pop) take over.
BackHandler(enabled = webViewRef.value?.canGoBack() == true) {
webViewRef.value?.goBack()
}
Column(modifier = Modifier.fillMaxSize()) {
Card(
@ -243,52 +269,89 @@ fun WebViewSyncSection(
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(4.dp))
Text(
text = currentUrl,
fontSize = 11.sp,
color = TextMuted,
modifier = Modifier.weight(1f),
maxLines = 1
)
Button(
onClick = {
webViewRef?.evaluateJavascript(
"(function() { return document.documentElement.outerHTML; })();"
) { html ->
if (!html.isNullOrBlank()) {
onExtractHistory(html)
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(end = 6.dp))
Text(
text = currentUrl,
fontSize = 11.sp,
color = TextMuted,
modifier = Modifier.weight(1f),
maxLines = 1
)
Button(
enabled = webSyncSupported && !isExtracting,
onClick = {
val webview = webViewRef.value ?: return@Button
isExtracting = true
statusMessage = "Lese Verlauf aus…"
runExtraction(webview, provider) { result ->
isExtracting = false
statusMessage = null
if (!result.isNullOrBlank() && result != "[]" && result != "null") {
onExtractHistory(result)
} else {
statusMessage = "Keine Daten gefunden. Stelle sicher, dass du eingeloggt bist und sich der Verlauf geladen hat."
}
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = provider.primaryColor)
) {
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
},
colors = ButtonDefaults.buttonColors(
containerColor = provider.primaryColor,
disabledContainerColor = provider.primaryColor.copy(alpha = 0.4f)
)
) {
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
}
}
statusMessage?.let { msg ->
Spacer(modifier = Modifier.height(6.dp))
Text(text = msg, fontSize = 11.sp, color = TextSecondary)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
if (!webSyncSupported) {
Box(
modifier = Modifier
.fillMaxSize()
.background(CardBackgroundDark, RoundedCornerShape(12.dp))
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = "Web-Sync für ${provider.displayName} wird noch nicht unterstützt.\nBitte nutze den CSV-Import.",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.padding(24.dp)
)
}
return
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
// Allow third-party cookies so login flows survive the redirect chain.
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
settings.userAgentString = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
currentUrl = url ?: ""
isLoadingWeb = false
// Install the JS<->Kotlin bridge: the extraction promise resolves
// into window.__bingeResult, which runExtraction() then polls.
view?.evaluateJavascript(
"window.__bingeResult = null; window.__bingeExtracted = function(v) { window.__bingeResult = (typeof v === 'string') ? v : JSON.stringify(v); };",
null
)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
@ -297,20 +360,17 @@ fun WebViewSyncSection(
}
}
loadUrl(provider.viewingHistoryUrl)
webViewRef = this
}
},
update = { webView ->
if (webView.url != provider.viewingHistoryUrl && !currentUrl.contains(provider.id)) {
webView.loadUrl(provider.viewingHistoryUrl)
webViewRef.value = this
}
},
// Removed the old aggressive reload: only react to provider changes, not every recomposition.
update = { _ -> },
modifier = Modifier
.fillMaxSize()
.border(1.dp, GlassCardBorder, RoundedCornerShape(8.dp))
)
if (isLoadingWeb) {
if (isLoadingWeb || isExtracting) {
CircularProgressIndicator(
color = provider.primaryColor,
modifier = Modifier.align(Alignment.Center)
@ -320,6 +380,59 @@ fun WebViewSyncSection(
}
}
/**
* Injects the provider-specific extraction script into the WebView. The Netflix script
* returns a Promise, so we wrap evaluation in a `.then()` and read the resolved JSON.
*/
private fun runExtraction(
webView: WebView,
provider: StreamingProvider,
onResult: (String?) -> Unit
) {
val script = when (provider) {
StreamingProvider.NETFLIX -> WebSyncScripts.netflix
StreamingProvider.AMAZON_PRIME -> WebSyncScripts.prime
else -> { onResult(null); return }
}
val wrapped = """
(function() {
var r = (function() { $script })();
if (r && typeof r.then === 'function') {
r.then(function(v) { __bingeExtracted(v); }).catch(function() { __bingeExtracted('[]'); });
} else {
__bingeExtracted(r);
}
})();
""".trimIndent()
// Bridge from JS back to Kotlin: store the result in a JS variable we then retrieve.
webView.evaluateJavascript(wrapped, null)
// Small delay so the async fetch (Netflix pagination) has time to complete before polling.
pollExtraction(webView, attempts = 0, onResult = onResult)
}
private fun pollExtraction(webView: WebView, attempts: Int, onResult: (String?) -> Unit) {
// The injected promise calls __bingeExtracted(v); we expose it via addJavascriptInterface-like
// retrieval: evaluate a getter. Cap attempts so we never spin forever.
val maxAttempts = 20
if (attempts > maxAttempts) {
onResult(null)
return
}
webView.postDelayed({
webView.evaluateJavascript(
"(window.__bingeResult === undefined) ? null : window.__bingeResult;"
) { raw ->
if (raw == null || raw == "null" || raw == "undefined") {
pollExtraction(webView, attempts + 1, onResult)
} else {
onResult(raw)
}
}
}, 500)
}
@Composable
fun CsvImportSection(
provider: StreamingProvider,

View file

@ -88,10 +88,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val items = if (jsonOrCsvText.trim().startsWith("{")) {
NetflixParser.parseJsonApiPayload(jsonOrCsvText)
val trimmed = jsonOrCsvText.trim()
// The WebView extraction returns a JSON array (or object); older flows may
// still pass CSV. Detect by the leading character.
val items = if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
when (provider) {
StreamingProvider.NETFLIX,
StreamingProvider.AMAZON_PRIME ->
NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
else -> NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
}
} else {
NetflixParser.parseCsv(jsonOrCsvText)
when (provider) {
StreamingProvider.AMAZON_PRIME -> PrimeVideoParser.parseCsv(trimmed)
else -> NetflixParser.parseCsv(trimmed)
}
}
if (items.isEmpty()) {

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#141414"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Rounded bar chart background mark in Netflix red -->
<path
android:fillColor="#E50914"
android:pathData="M34,64h8v16h-8zM46,52h8v28h-8zM58,40h8v40h-8zM70,28h8v52h-8z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">BingeStats</string>
</resources>

View file

@ -0,0 +1,4 @@
<resources>
<!-- Base theme for the Compose app: dark, no action bar -->
<style name="Theme.BingeStats" parent="android:Theme.Material.NoActionBar" />
</resources>