From 76f37e9e19a18656168b22170886035c0071fc31 Mon Sep 17 00:00:00 2001 From: Ivor <179814302+Ivorisnoob@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:17:39 +0530 Subject: [PATCH] Continue Watching home-screen widget A 4x3 card showing the newest unfinished title: still, badge, progress bar, title and episode name. With nothing to continue it shows an empty state and opens Home. Everything on the card comes from Room, so it is complete offline; only the poster needs the network. RemoteViews, not Glance, so there is no new dependency. Glance's Image cannot fetch anything either, so both routes decode the bitmap in the app; all Glance adds here is declarative syntax. A RemoteViews widget also cannot scroll without a RemoteViewsService and an R8 keep rule, and the launcher shortcut already jumps straight to the newest item, so one card matches what the app does. Reading and drawing both happen in the app's process; only inflation happens in the launcher's. So onUpdate reads Room inside goAsync() and a flow subscription from the Application scope repaints on every progress change. No WorkManager and no snapshot: a periodic job would be inexact, Doze-deferred, and would leave the first render showing nothing for up to its interval. continueWatching follows the active profile, so switching profiles repaints too. PlaybackSession is untouched - every mutation already flows through WatchProgressRepository. The tap is a TMDB VIEW intent, which MainActivity's existing deep link path already turns into Details, so no navigation code is needed. Details and Home use different request codes and data, so the two PendingIntents stay distinct. The layout uses plain colour resources rather than theme attributes, because a RemoteViews layout is inflated in the launcher and ?attr/... would resolve against the launcher's theme. values-v31 aliases the system Material You roles for dynamic colour at reduced fidelity: it follows the wallpaper, not the app's dynamicColor setting, because a resource alias cannot be conditional. Dark mode follows the system setting, not AppSettings.themeMode - a user who forces Light while the system is dark gets a dark widget. WatchProgress.badge() moves to presentation/components so the rail and the widget cannot drift on the up-next case, where the fraction is 0 and an unconditional progress bar would read as broken. Not verified on a device; CLAUDE.md says the maintainer tests. Compiled and assembled, and the receiver was checked in the merged manifest. The release build's R8 behaviour was not exercised. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- app/src/main/AndroidManifest.xml | 15 +++ .../java/com/ivor/openstream/OpenStreamApp.kt | 11 ++ .../components/ContinueWatchingLabels.kt | 16 +++ .../presentation/home/ContinueWatchingRail.kt | 10 +- .../widget/ContinueWatchingArtwork.kt | 94 ++++++++++++++ .../widget/ContinueWatchingProvider.kt | 42 +++++++ .../widget/ContinueWatchingWidget.kt | 101 +++++++++++++++ .../widget/ContinueWatchingWidgetRefresher.kt | 48 +++++++ .../main/res/drawable/widget_background.xml | 7 ++ .../res/layout/widget_continue_watching.xml | 118 ++++++++++++++++++ app/src/main/res/values-night-v31/colors.xml | 10 ++ app/src/main/res/values-night/colors.xml | 11 ++ app/src/main/res/values-v31/colors.xml | 12 ++ app/src/main/res/values/colors.xml | 10 ++ app/src/main/res/values/strings.xml | 8 ++ .../xml/openstream_continue_watching_info.xml | 18 +++ docs/ROADMAP.md | 6 +- 18 files changed, 536 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/presentation/components/ContinueWatchingLabels.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingArtwork.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingProvider.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidget.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidgetRefresher.kt create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/layout/widget_continue_watching.xml create mode 100644 app/src/main/res/values-night-v31/colors.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/xml/openstream_continue_watching_info.xml diff --git a/CLAUDE.md b/CLAUDE.md index a7b4c82..4bbdcfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,8 @@ data/streaming Source resolution, extension -> provider registry, id mapping data/extensions Extension catalog: repos, cache, parser, ranking, bundled copy data/service Media3 download service domain Models and repository interfaces -presentation Screens + ViewModels; player/session holds the app-wide player +presentation Screens + ViewModels; player/session holds the app-wide player; widget is the + home-screen Continue Watching card ui/theme Colors, type, ExpressiveShapes ``` @@ -135,7 +136,13 @@ Rules: - **Backup / diagnostics.** `LibraryBackup` (JSON, merge on restore) and `Diagnostics` (crash files in `filesDir/crashes`, recorder installed in `OpenStreamApp`) back the Settings entries. - **Deep links.** `MainActivity` is `singleTask`; `DeepLinks` turns TMDB links (VIEW or shared text) - into Details, `AppShortcut` handles launcher shortcuts. + into Details, `AppShortcut` handles launcher shortcuts. The Continue Watching widget reuses the + same path: `presentation/widget/` builds a TMDB VIEW intent as the tap `PendingIntent`, so a + widget tap needs no navigation code. The widget is RemoteViews, not Glance, and reads Room + directly inside `goAsync()`; `ContinueWatchingWidgetRefresher` repaints it from the Application + scope on every progress change rather than on a timer. Its layout uses plain colour resources + (`values-v31` aliases the system Material You roles) because a RemoteViews layout is inflated in + the launcher's process, where `?attr/…` would resolve against the launcher's theme. - **Downloads.** Everything goes through Media3's `DownloadManager` (`DownloadRepositoryImpl`): queue resolves sources in the app scope, live progress from the manager, pause/resume/retry, one rendition up to 1080p from master playlists. Offline playback reads the same cache. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf5f93c..aa4496e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,6 +82,21 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_provider_paths" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openstream/OpenStreamApp.kt b/app/src/main/java/com/ivor/openstream/OpenStreamApp.kt index aabac74..6be3738 100644 --- a/app/src/main/java/com/ivor/openstream/OpenStreamApp.kt +++ b/app/src/main/java/com/ivor/openstream/OpenStreamApp.kt @@ -7,7 +7,11 @@ import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import com.ivor.openstream.data.diagnostics.Diagnostics import com.ivor.openstream.data.settings.AppDns +import com.ivor.openstream.presentation.widget.ContinueWatchingWidgetRefresher import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient import javax.inject.Inject @@ -17,9 +21,16 @@ class OpenStreamApp : Application(), SingletonImageLoader.Factory { @Inject lateinit var appDns: AppDns + @Inject + lateinit var widgetRefresher: ContinueWatchingWidgetRefresher + + /** Outlives every Activity, which is what the widget needs for background playback. */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override fun onCreate() { super.onCreate() Diagnostics.installCrashRecorder(this) + widgetRefresher.start(appScope) } /** Artwork goes through the app's DNS setting too, so posters load where TMDB's DNS is blocked. */ diff --git a/app/src/main/java/com/ivor/openstream/presentation/components/ContinueWatchingLabels.kt b/app/src/main/java/com/ivor/openstream/presentation/components/ContinueWatchingLabels.kt new file mode 100644 index 0000000..c49db82 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/components/ContinueWatchingLabels.kt @@ -0,0 +1,16 @@ +package com.ivor.openstream.presentation.components + +import com.ivor.openstream.domain.model.WatchProgress + +/** + * The line over the artwork on a Continue Watching card: what is being watched and how much is + * left. Shared with the home-screen widget so the two cannot drift apart. + */ +fun WatchProgress.badge(): String { + val remaining = ((durationMs - positionMs) / 60_000L).coerceAtLeast(1L) + return when { + isUpNext && !isMovie -> "Up next · S$season E$episode" + isMovie -> "${remaining}m left" + else -> "S$season E$episode · ${remaining}m left" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openstream/presentation/home/ContinueWatchingRail.kt b/app/src/main/java/com/ivor/openstream/presentation/home/ContinueWatchingRail.kt index c14a2f5..46c1fa7 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/home/ContinueWatchingRail.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/home/ContinueWatchingRail.kt @@ -1,5 +1,6 @@ package com.ivor.openstream.presentation.home +import com.ivor.openstream.presentation.components.badge import com.ivor.openstream.presentation.components.byWidth import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -192,12 +193,3 @@ private fun ContinueWatchingCard( } } } - -private fun WatchProgress.badge(): String { - val remaining = ((durationMs - positionMs) / 60_000L).coerceAtLeast(1L) - return when { - isUpNext && !isMovie -> "Up next · S$season E$episode" - isMovie -> "${remaining}m left" - else -> "S$season E$episode · ${remaining}m left" - } -} diff --git a/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingArtwork.kt b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingArtwork.kt new file mode 100644 index 0000000..3e41e75 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingArtwork.kt @@ -0,0 +1,94 @@ +package com.ivor.openstream.presentation.widget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.ivor.openstream.domain.model.WatchProgress +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Poster art for the Continue Watching widget. + * + * RemoteViews has no image loader and cannot reach the app's disk cache, so the bitmap is fetched + * and downsampled here and kept in `cacheDir`. The client is the app's streaming one, which already + * goes through `AppDns` (see `NetworkModule`), so artwork loads where TMDB's DNS is blocked. + */ +@Singleton +class ContinueWatchingArtwork @Inject constructor( + @ApplicationContext private val context: Context, + @Named("StreamingClient") client: OkHttpClient +) { + + /** Short timeouts: a widget that misses its window shows no art at all. */ + private val client = client.newBuilder().callTimeout(8, java.util.concurrent.TimeUnit.SECONDS).build() + + private val directory: File by lazy { File(context.cacheDir, "widget-art").apply { mkdirs() } } + + /** Cached art for [item], or null when it cannot be fetched. Never throws. */ + suspend fun load(item: WatchProgress): Bitmap? = withContext(Dispatchers.IO) { + val path = item.stillPath ?: item.backdropPath ?: item.posterPath ?: return@withContext null + val url = "https://image.tmdb.org/t/p/w500$path" + val file = File(directory, hash(url)) + if (file.exists()) { + BitmapFactory.decodeFile(file.absolutePath)?.takeIf { it.byteCount <= MAX_BYTES }?.let { return@withContext it } + } + decode(download(url) ?: return@withContext null)?.also { bitmap -> + runCatching { file.writeBytes(bitmap.toJpegBytes()) } + } + } + + private fun download(url: String): ByteArray? = runCatching { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) return null + response.body?.bytes() + } + }.getOrNull() + + /** + * Decoded at roughly [TARGET_WIDTH] so the parcelled bitmap stays well inside the Binder + * transaction limit. Two passes: measure, then sample. + */ + private fun decode(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSizeFor(bounds.outWidth) + inPreferredConfig = Bitmap.Config.RGB_565 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + ?.takeIf { it.byteCount <= MAX_BYTES } + } + + private fun sampleSizeFor(sourceWidth: Int): Int { + if (sourceWidth <= 0) return 1 + var sample = 1 + while (sourceWidth / (sample * 2) >= TARGET_WIDTH) sample *= 2 + return sample + } + + private fun Bitmap.toJpegBytes(): ByteArray = + java.io.ByteArrayOutputStream().use { out -> + compress(Bitmap.CompressFormat.JPEG, 85, out) + out.toByteArray() + } + + private fun hash(url: String) = MessageDigest.getInstance("SHA-256") + .digest(url.toByteArray()) + .joinToString("") { "%02x".format(it) } + .take(32) + + private companion object { + /** Roughly a 250dp card at 2x. A larger bitmap risks TransactionTooLargeException. */ + const val TARGET_WIDTH = 480 + const val MAX_BYTES = 200 * 1024 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingProvider.kt b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingProvider.kt new file mode 100644 index 0000000..0489019 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingProvider.kt @@ -0,0 +1,42 @@ +package com.ivor.openstream.presentation.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Home-screen entry point for the Continue Watching widget. Only [onUpdate] needs work: the widget + * is redrawn by [ContinueWatchingWidgetRefresher] whenever progress changes. + */ +@AndroidEntryPoint +class ContinueWatchingProvider : AppWidgetProvider() { + + @Inject + lateinit var widget: ContinueWatchingWidget + + /** A plain receiver has no lifecycle, so the work runs on its own scope. */ + private val scope = CoroutineScope(Dispatchers.Default) + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + if (appWidgetIds.isEmpty()) return + // onReceive has an ANR budget and reading progress is a suspend query, so keep the + // receiver alive until it finishes. + val pendingResult = goAsync() + scope.launch { + try { + widget.render() + } finally { + pendingResult.finish() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidget.kt b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidget.kt new file mode 100644 index 0000000..bd16030 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidget.kt @@ -0,0 +1,101 @@ +package com.ivor.openstream.presentation.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import com.ivor.openstream.MainActivity +import com.ivor.openstream.R +import com.ivor.openstream.domain.model.WatchProgress +import com.ivor.openstream.domain.repository.WatchProgressRepository +import com.ivor.openstream.presentation.components.badge +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Draws the Continue Watching widget. + * + * Reading and drawing both happen in the app's process; only inflating and compositing happen in + * the launcher's, which is why the layout uses plain colour resources instead of theme attributes. + */ +@Singleton +class ContinueWatchingWidget @Inject constructor( + @ApplicationContext private val context: Context, + private val watchProgressRepository: WatchProgressRepository, + private val artwork: ContinueWatchingArtwork +) { + + /** Repaints every placed instance. Safe to call when none are placed. */ + suspend fun render() { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds(ComponentName(context, ContinueWatchingProvider::class.java)) + if (ids.isEmpty()) return + // One read for all instances, not one per instance. + val item = watchProgressRepository.continueWatching(limit = 1).first().firstOrNull() + manager.updateAppWidget(ids, viewsFor(item)) + } + + private suspend fun viewsFor(item: WatchProgress?): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.widget_continue_watching) + if (item == null) { + views.setViewVisibility(R.id.widget_continue_filled, android.view.View.GONE) + views.setViewVisibility(R.id.widget_continue_empty, android.view.View.VISIBLE) + views.setOnClickPendingIntent(R.id.widget_continue_root, openHome()) + return views + } + + views.setViewVisibility(R.id.widget_continue_filled, android.view.View.VISIBLE) + views.setViewVisibility(R.id.widget_continue_empty, android.view.View.GONE) + views.setTextViewText(R.id.widget_continue_badge, item.badge()) + views.setTextViewText(R.id.widget_continue_title, item.title) + views.setTextViewText( + R.id.widget_continue_subtitle, + item.episodeTitle?.takeIf { !item.isMovie } ?: item.mediaType.replaceFirstChar { it.uppercase() } + ) + + // A queued "up next" episode has no position yet, so its fraction is 0; showing an empty + // bar there reads as "broken" rather than "not started". + val started = item.fraction > 0f + views.setViewVisibility(R.id.widget_continue_progress, if (started) android.view.View.VISIBLE else android.view.View.GONE) + if (started) { + // RemoteViews.setProgressBar only takes progress, max and indeterminate here; the bar + // takes its colour from the theme's progressBarStyle, so there is no tint to set. + views.setProgressBar(R.id.widget_continue_progress, (item.fraction * 100).toInt(), 100, false) + } + + // Every field above comes from Room, so the card is complete offline; only art needs the + // network. On failure the placeholder colour behind the ImageView is what shows. + artwork.load(item)?.let { views.setImageViewBitmap(R.id.widget_continue_art, it) } + views.setContentDescription(R.id.widget_continue_root, item.title) + + views.setOnClickPendingIntent(R.id.widget_continue_root, openDetails(item)) + return views + } + + /** + * The tap reuses the TMDB deep link the app already handles, so the widget needs no new + * navigation: `MainActivity` is singleTask with an `onNewIntent`, its VIEW filter covers + * themoviedb.org movie and TV paths, and `DeepLinks` turns that into a Details route. + */ + private fun openDetails(item: WatchProgress): PendingIntent { + val type = if (item.isMovie) "movie" else "tv" + val intent = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(android.net.Uri.parse("https://www.themoviedb.org/$type/${item.tmdbId}")) + return PendingIntent.getActivity(context, item.tmdbId, intent, immutableFlags()) + } + + private fun openHome(): PendingIntent { + val intent = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + return PendingIntent.getActivity(context, 0, intent, immutableFlags()) + } + + private fun immutableFlags() = + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +} \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidgetRefresher.kt b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidgetRefresher.kt new file mode 100644 index 0000000..7bf5d77 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/widget/ContinueWatchingWidgetRefresher.kt @@ -0,0 +1,48 @@ +package com.ivor.openstream.presentation.widget + +import com.ivor.openstream.domain.model.WatchProgress +import com.ivor.openstream.domain.repository.WatchProgressRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repaints the Continue Watching widget as progress changes, while the app process is alive. + * + * Every mutation already flows through `WatchProgressRepository` (the player recording progress, + * Home removing a title, Details marking episodes watched, a profile switch), so subscribing to it + * covers the lot without the widget being wired into any of them. `continueWatching` follows the + * active profile, so a switch repaints the widget too. + * + * A periodic job would be worse here: the process is already awake during the only window where + * progress changes, and a WorkManager periodic request is inexact and Doze-deferred. The widget's + * own `updatePeriodMillis` repairs anything missed while the process was dead. + */ +@Singleton +class ContinueWatchingWidgetRefresher @Inject constructor( + private val watchProgressRepository: WatchProgressRepository, + private val widget: ContinueWatchingWidget +) { + + /** + * [scope] must outlive any Activity, so callers pass the application scope. Nothing renders + * unless a widget is actually placed. + */ + fun start(scope: CoroutineScope) { + scope.launch { + watchProgressRepository.continueWatching(limit = 1) + // Rounding to whole percent keeps a 10s progress save from repainting 270 times + // an hour for changes too small to see. + .map { row -> row.firstOrNull()?.renderKey() } + .distinctUntilChanged() + .collect { widget.render() } + } + } + + private fun WatchProgress.renderKey() = + listOf(tmdbId, mediaType, season, episode, title, episodeTitle, stillPath, (fraction * 100).toInt()) + .joinToString("|") +} \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..3281350 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_continue_watching.xml b/app/src/main/res/layout/widget_continue_watching.xml new file mode 100644 index 0000000..9965279 --- /dev/null +++ b/app/src/main/res/layout/widget_continue_watching.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000..ed50449 --- /dev/null +++ b/app/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,10 @@ + + + + @android:color/system_accent1_200 + @android:color/system_neutral1_900 + @android:color/system_neutral1_50 + @android:color/system_neutral1_300 + @android:color/system_neutral1_800 + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..9a06904 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,11 @@ + + + + #1C1B1F + #E6E1E5 + #CAC4D0 + #2B2930 + #D0BCFF + \ No newline at end of file diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml new file mode 100644 index 0000000..472c15a --- /dev/null +++ b/app/src/main/res/values-v31/colors.xml @@ -0,0 +1,12 @@ + + + + @android:color/system_accent1_600 + @android:color/system_neutral1_10 + @android:color/system_neutral1_900 + @android:color/system_neutral1_700 + @android:color/system_neutral1_100 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..c462d2c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,14 @@ #FF018786 #FF000000 #FFFFFFFF + + + #FFFBFE + #1C1B1F + #49454F + #E7E0EC + #FFFFFFFF + #6F43C0 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2150029..4fa328f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,12 @@ + OpenStream Downloads + + + Continue watching + Pick up the title you were last watching. + Continue watching + Start something and it shows up here. \ No newline at end of file diff --git a/app/src/main/res/xml/openstream_continue_watching_info.xml b/app/src/main/res/xml/openstream_continue_watching_info.xml new file mode 100644 index 0000000..9d257da --- /dev/null +++ b/app/src/main/res/xml/openstream_continue_watching_info.xml @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 595da27..20a0509 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -125,7 +125,11 @@ brightness back to the system. - [ ] **Android TV** (leanback launcher entry, D-pad focus). - [x] **Predictive back** (`android:enableOnBackInvokedCallback`). - [x] **App shortcuts** (Continue Watching, Search, Downloads). -- [ ] **Continue Watching widget.** +- [x] **Continue Watching widget.** A 4x3 home-screen card showing the newest unfinished title: + still, badge, progress bar and episode name. Tapping opens Details through the same TMDB + deep link the app already handles, so there is no new navigation. RemoteViews rather than + Glance, so no new dependency; read straight from Room in `goAsync()` with a flow subscription + from the Application scope, so no WorkManager either. - [ ] **Shared element transitions** from cards to Details artwork. - [x] **Crash and log export** (local file) for bug reports. - [ ] **Media3 upgrade.** The project is on 1.3.1; newer releases bring session, HLS and