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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>

<!-- Continue Watching widget. Exported because the launcher binds and broadcasts to it;
an intent filter with no explicit exported fails the merge on API 31+. -->
<receiver
android:name=".presentation.widget.ContinueWatchingProvider"
android:exported="true"
android:label="@string/widget_continue_watching_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/openstream_continue_watching_info" />
</receiver>
</application>

</manifest>
11 changes: 11 additions & 0 deletions app/src/main/java/com/ivor/openstream/OpenStreamApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading