Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
87c9655
docs: specify playlists feature
anilbeesetti Jul 15, 2026
a1b481e
docs: add editable playlist reordering
anilbeesetti Jul 15, 2026
72a658f
docs: plan playlists implementation
anilbeesetti Jul 15, 2026
c3b7854
feat: add playlist persistence
anilbeesetti Jul 15, 2026
4ab3878
fix: order playlist detail items
anilbeesetti Jul 15, 2026
326af37
feat: parse linked m3u sources
anilbeesetti Jul 15, 2026
6c55e93
fix: validate linked playlist entries
anilbeesetti Jul 15, 2026
0ed268b
feat: add playlist repository
anilbeesetti Jul 15, 2026
f95d06a
fix: harden playlist repository validation
anilbeesetti Jul 15, 2026
7147bd7
feat: add playlist creation screen
anilbeesetti Jul 15, 2026
9633837
fix: prepare playlist documents off main thread
anilbeesetti Jul 15, 2026
43e9ad9
fix: own playlist document preparation requests
anilbeesetti Jul 15, 2026
f2f38ba
feat: add playlist playback and refresh
anilbeesetti Jul 15, 2026
d73e1c7
fix: reconcile playlist reorder lifecycle
anilbeesetti Jul 15, 2026
33a39c4
fix: await persisted playlist order
anilbeesetti Jul 15, 2026
bdf6a0b
fix: bound playlist convergence generations
anilbeesetti Jul 15, 2026
4e40aff
feat: add selected media to playlists
anilbeesetti Jul 15, 2026
d1a7750
fix: make playlist creation retry safe
anilbeesetti Jul 15, 2026
e2601ae
fix: recover playlist dialog after cancellation
anilbeesetti Jul 15, 2026
915780d
feat: add playlists navigation tab
anilbeesetti Jul 15, 2026
f82f908
fix: start playlists at selected uri
anilbeesetti Jul 15, 2026
98ce3a5
fix: read current player intent data
anilbeesetti Jul 15, 2026
a56ce3a
test: verify playlist feature
anilbeesetti Jul 15, 2026
96dc804
fix: clear playlist lint findings
anilbeesetti Jul 15, 2026
9543ea4
test: fix playlist emulator assertions
anilbeesetti Jul 15, 2026
5ddd7ff
test: validate playlists on fresh emulator
anilbeesetti Jul 15, 2026
9d54da4
fix: share player artwork safely
anilbeesetti Jul 15, 2026
b504850
test: verify playlist QA corrections
anilbeesetti Jul 15, 2026
52f25f5
fix: harden playlist playback and sources
anilbeesetti Jul 15, 2026
5b34121
test: verify final playlist hardening
anilbeesetti Jul 16, 2026
78d9085
docs: design playlist metadata refinements
anilbeesetti Jul 16, 2026
e55974c
docs: plan playlist metadata refinements
anilbeesetti Jul 16, 2026
aad3688
feat: persist playlist item metadata
anilbeesetti Jul 16, 2026
fc19e93
feat: parse artwork from large m3u playlists
anilbeesetti Jul 16, 2026
1e5cae4
feat: refine playlist presentation
anilbeesetti Jul 16, 2026
2d48607
feat: load playlist metadata into player queues
anilbeesetti Jul 16, 2026
f66d7d3
fix: use local playlist copy
anilbeesetti Jul 16, 2026
ff43efa
fix: cancel stale playlist requests
anilbeesetti Jul 16, 2026
5e095e3
test: document playlist metadata emulator qa
anilbeesetti Jul 16, 2026
4126e23
fix: harden large playlist processing
anilbeesetti Jul 16, 2026
eb95c2e
update some ui elements
anilbeesetti Jul 25, 2026
94bfde6
remove superpowers folder
anilbeesetti Jul 25, 2026
b0c50a9
Merge remote-tracking branch 'origin/main' into codex/playlist-feature
anilbeesetti Jul 25, 2026
f8d23bd
remove superpowers folder
anilbeesetti Jul 25, 2026
5530197
remove superpowers docs folder
anilbeesetti Jul 25, 2026
1db9118
remove unneeded folders
anilbeesetti Jul 25, 2026
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ fastlane/test_output
vendor/
.bundle/
.kotlin
.junie
.junie
.superpowers
docs
qa
74 changes: 0 additions & 74 deletions .superpowers/sdd/task-1-report.md

This file was deleted.

2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ dependencies {
implementation(project(":core:model"))
implementation(project(":core:ui"))
implementation(project(":feature:network"))
implementation(project(":feature:playlist"))
implementation(project(":feature:videopicker"))
implementation(project(":feature:player"))
implementation(project(":feature:settings"))
Expand All @@ -128,6 +129,7 @@ dependencies {
implementation(libs.androidx.core.splashscreen)

implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)

// Hilt
implementation(libs.hilt.android)
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
android:banner="@drawable/tv_banner"
android:supportsRtl="true"
android:theme="@style/Theme.NextPlayer.Splash"
android:usesCleartextTraffic="true"
tools:targetApi="s">
<activity
android:name=".MainActivity"
Expand All @@ -55,4 +56,4 @@
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>
</manifest>
7 changes: 5 additions & 2 deletions app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import dev.anilbeesetti.nextplayer.navigation.TopLevelDestination
import dev.anilbeesetti.nextplayer.navigation.isNavigationBetweenTopLevelDestinations
import dev.anilbeesetti.nextplayer.navigation.mediaNavGraph
import dev.anilbeesetti.nextplayer.navigation.networkNavGraph
import dev.anilbeesetti.nextplayer.navigation.playlistNavGraph
import dev.anilbeesetti.nextplayer.navigation.rememberResponsiveNavigationSceneDecoratorStrategy
import dev.anilbeesetti.nextplayer.navigation.rememberTopLevelNavState
import dev.anilbeesetti.nextplayer.navigation.settingsNavGraph
Expand Down Expand Up @@ -127,12 +128,14 @@ class MainActivity : ComponentActivity() {
)

val mediaStack = navState.backStacks.getValue(TopLevelDestination.MEDIA.route)
val playlistStack = navState.backStacks.getValue(TopLevelDestination.PLAYLISTS.route)
val networkStack = navState.backStacks.getValue(TopLevelDestination.NETWORK.route)

// Media and network entries navigate within their own tab's stack; settings is
// shared, so it navigates within whichever tab it was opened from (the current one).
// Each top-level feature navigates within its own stack. Settings is shared, so
// it navigates within whichever tab it was opened from (the current one).
val provider = entryProvider {
mediaNavGraph(context = this@MainActivity, backStack = mediaStack)
playlistNavGraph(context = this@MainActivity, backStack = playlistStack)
networkNavGraph(context = this@MainActivity, backStack = networkStack)
settingsNavGraph(backStack = navState.currentStack)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.anilbeesetti.nextplayer

internal const val MAX_CACHED_THUMBNAIL_BYTES = 8L * 1024 * 1024
internal const val MAX_CACHED_THUMBNAIL_DIMENSION = 16_384

private const val MAX_CACHED_THUMBNAIL_PIXELS = 100_000_000L

internal fun isSafeCachedThumbnail(
encodedByteCount: Long,
width: Int,
height: Int,
): Boolean = encodedByteCount in 1..MAX_CACHED_THUMBNAIL_BYTES &&
width in 1..MAX_CACHED_THUMBNAIL_DIMENSION &&
height in 1..MAX_CACHED_THUMBNAIL_DIMENSION &&
width.toLong() * height <= MAX_CACHED_THUMBNAIL_PIXELS

internal fun calculateThumbnailInSampleSize(
sourceWidth: Int,
sourceHeight: Int,
targetWidth: Int,
targetHeight: Int,
): Int {
if (targetWidth <= 0 || targetHeight <= 0) return 1
var sampleSize = 1
while (
sourceWidth / (sampleSize * 2) >= targetWidth &&
sourceHeight / (sampleSize * 2) >= targetHeight
) {
sampleSize *= 2
}
return sampleSize
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,29 @@ import android.os.Build.VERSION.SDK_INT
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.get
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.asImage
import coil3.decode.ContentMetadata
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.disk.DiskCache
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.toAndroidUri
import okio.FileSystem
import androidx.core.graphics.get
import io.github.anilbeesetti.nextlib.mediainfo.MediaThumbnailRetriever
import kotlin.math.abs
import coil3.decode.DecodeUtils
import coil3.request.maxBitmapSize
import coil3.size.Precision
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.toAndroidUri
import coil3.util.component1
import coil3.util.component2
import io.github.anilbeesetti.nextlib.mediainfo.MediaThumbnailRetriever
import kotlin.math.abs
import kotlin.math.roundToInt
import okio.FileSystem

class VideoThumbnailDecoder(
private val source: ImageSource,
Expand All @@ -53,28 +53,43 @@ class VideoThumbnailDecoder(
override suspend fun decode(): DecodeResult {
readFromDiskCache()?.use { snapshot ->
val file = snapshot.data.toFile()
if (file.length() !in 1..MAX_CACHED_THUMBNAIL_BYTES) return@use

// Read cached image dimensions (no pixel allocation)
val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
file.inputStream().use { BitmapFactory.decodeStream(it, null, boundsOpts) }
val cachedWidth = boundsOpts.outWidth
val cachedHeight = boundsOpts.outHeight

if (!isSafeCachedThumbnail(file.length(), cachedWidth, cachedHeight)) return@use

// Determine what size the caller actually wants
val requestedWidth = options.size.width.pxOrElse { 0 }
val requestedHeight = options.size.height.pxOrElse { 0 }

// Cache is sufficient only if requested size <= cached size (or size is unspecified)
val cacheIsSufficient = (requestedWidth == 0 || requestedWidth <= cachedWidth) &&
(requestedHeight == 0 || requestedHeight <= cachedHeight)
(requestedHeight == 0 || requestedHeight <= cachedHeight)

if (cacheIsSufficient) {
val dstSize = computeDstSize(cachedWidth, cachedHeight)
val sampledBitmap = file.inputStream().use { BitmapFactory.decodeStream(it) }
val targetWidth = dstSize.width.pxOrElse { cachedWidth }
val targetHeight = dstSize.height.pxOrElse { cachedHeight }
val decodeOptions = BitmapFactory.Options().apply {
inSampleSize = calculateThumbnailInSampleSize(
sourceWidth = cachedWidth,
sourceHeight = cachedHeight,
targetWidth = targetWidth,
targetHeight = targetHeight,
)
}
val sampledBitmap = file.inputStream().use {
BitmapFactory.decodeStream(it, null, decodeOptions)
} ?: return@use
val normalizedBitmap = normalizeBitmap(
inBitmap = sampledBitmap,
srcWidth = cachedWidth,
srcHeight = cachedHeight,
srcWidth = sampledBitmap.width,
srcHeight = sampledBitmap.height,
dstSize = dstSize,
)
return DecodeResult(
Expand All @@ -99,9 +114,7 @@ class VideoThumbnailDecoder(

// First, try to get embedded picture (album art/metadata thumbnail)
val embeddedPicture = nativeRetriever.embeddedPicture ?: ffmpegRetriever.getEmbeddedPicture()
val embeddedPictureBitmap = embeddedPicture?.let { pictureBytes ->
BitmapFactory.decodeByteArray(pictureBytes, 0, pictureBytes.size)
}
val embeddedPictureBitmap = embeddedPicture?.let(::decodeEmbeddedPicture)

if (embeddedPictureBitmap != null) return@use embeddedPictureBitmap

Expand Down Expand Up @@ -208,6 +221,31 @@ class VideoThumbnailDecoder(
}
}

private fun decodeEmbeddedPicture(pictureBytes: ByteArray): Bitmap? {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(pictureBytes, 0, pictureBytes.size, boundsOptions)
if (
!isSafeCachedThumbnail(
encodedByteCount = pictureBytes.size.toLong(),
width = boundsOptions.outWidth,
height = boundsOptions.outHeight,
)
) {
return null
}

val dstSize = computeDstSize(boundsOptions.outWidth, boundsOptions.outHeight)
val decodeOptions = BitmapFactory.Options().apply {
inSampleSize = calculateThumbnailInSampleSize(
sourceWidth = boundsOptions.outWidth,
sourceHeight = boundsOptions.outHeight,
targetWidth = dstSize.width.pxOrElse { boundsOptions.outWidth },
targetHeight = dstSize.height.pxOrElse { boundsOptions.outHeight },
)
}
return BitmapFactory.decodeByteArray(pictureBytes, 0, pictureBytes.size, decodeOptions)
}

private fun normalizeBitmap(inBitmap: Bitmap, srcWidth: Int, srcHeight: Int, dstSize: Size): Bitmap {
val scale = DecodeUtils.computeSizeMultiplier(
srcWidth = srcWidth,
Expand Down Expand Up @@ -358,8 +396,8 @@ private fun isSolidColor(bitmap: Bitmap, threshold: Float = 0.7f): Boolean {
val b = color and 0xFF

abs(r - referenceR) <= tolerance &&
abs(g - referenceG) <= tolerance &&
abs(b - referenceB) <= tolerance
abs(g - referenceG) <= tolerance &&
abs(b - referenceB) <= tolerance
}

val similarityRatio = similarCount.toFloat() / sampledColors.size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,63 @@ fun EntryProviderScope<NavKey>.mediaNavGraph(
}

internal fun Context.startPlayback(uri: Uri, grantReadPermission: Boolean = false) {
startPlayback(uri = uri, playlist = null, grantReadPermission = grantReadPermission)
startPlayback(
uri = uri,
playlist = null,
grantReadPermission = grantReadPermission,
)
}

internal fun Context.startPlayback(uris: List<Uri>, grantReadPermission: Boolean = false) {
val uri = uris.firstOrNull() ?: return
startPlayback(uri = uri, playlist = uris, grantReadPermission = grantReadPermission)
internal fun Context.startPlayback(
uris: List<Uri>,
startUri: Uri? = null,
grantReadPermission: Boolean = false,
) {
val uri = startUri ?: uris.firstOrNull() ?: return
startPlayback(
uri = uri,
playlist = uris,
grantReadPermission = grantReadPermission,
)
}

private fun Context.startPlayback(uri: Uri, playlist: List<Uri>?, grantReadPermission: Boolean) {
if (grantReadPermission) {
(playlist ?: listOf(uri)).forEach {
grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
private fun Context.startPlayback(
uri: Uri,
playlist: List<Uri>?,
grantReadPermission: Boolean,
) {
val items = playlist ?: listOf(uri)
val spec = playbackLaunchSpec(
items = items,
startItem = uri,
grantReadPermission = grantReadPermission,
includePlaylist = playlist != null,
)
if (spec.grantReadPermission) {
items.forEach { grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
}
val intent = Intent(this, PlayerActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = uri
playlist?.let { putParcelableArrayListExtra(PlayerApi.API_PLAYLIST, ArrayList(it)) }
if (grantReadPermission) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
data = spec.startItem
spec.playlistExtra?.let { putParcelableArrayListExtra(PlayerApi.API_PLAYLIST, it) }
if (spec.grantReadPermission) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
}

internal data class PlaybackLaunchSpec<T>(
val startItem: T,
val playlistExtra: ArrayList<T>?,
val grantReadPermission: Boolean,
)

internal fun <T> playbackLaunchSpec(
items: List<T>,
startItem: T = items.first(),
grantReadPermission: Boolean = false,
includePlaylist: Boolean = items.size > 1,
): PlaybackLaunchSpec<T> = PlaybackLaunchSpec(
startItem = startItem,
playlistExtra = items.takeIf { includePlaylist }?.let { ArrayList(it) },
grantReadPermission = grantReadPermission,
)
Loading
Loading