Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
59513e8
fix(music): keep the gapless advance instead of seeking to the start
A-EDev Oct 19, 2026
8f46edb
fix(player): release the placeholder surface after the new surface is…
A-EDev Oct 19, 2026
04bf93d
fix(strings): restore three English strings that Weblate turned into …
A-EDev Oct 19, 2026
b458055
fix(player): re-prepare once after a video decoder error
A-EDev Oct 19, 2026
955da41
fix(player): show Retry when a decoder error ends playback
A-EDev Oct 19, 2026
18eb37b
fix(dearrow): show a video frame when nothing is submitted, from one …
A-EDev Oct 19, 2026
9cba6a8
feat(dearrow): choose whether to use a video frame when nothing is su…
A-EDev Oct 19, 2026
de4083f
feat(dearrow): DeArrow titles on Shorts cards
A-EDev Oct 19, 2026
4df6a4b
fix(music): route every shuffle through one queue shuffle
A-EDev Oct 19, 2026
089c50b
fix(music): show shuffle and repeat on the system media card
A-EDev Oct 19, 2026
baa167e
feat(player): repeat and shuffle on the video notification
A-EDev Oct 19, 2026
de63ba8
docs(changelog): resume after screen off, DeArrow frames, gapless son…
A-EDev Oct 19, 2026
4ad06de
fix(music): bring shuffle back with the saved queue so a restore neve…
A-EDev Oct 19, 2026
473bea1
style: format the DeArrow, Shorts card and video session changes
A-EDev Oct 19, 2026
655007c
refactor(player): keep the video session's default commands when addi…
A-EDev Oct 19, 2026
8de085a
fix(dearrow): hide the badge when the frame is not ready and YouTube'…
A-EDev Oct 19, 2026
29a08e3
fix(player): give video session controllers the trusted default comma…
A-EDev Oct 19, 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
11 changes: 11 additions & 0 deletions app/src/main/assets/changelog/v3.0.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@ ANDROID TV: SEARCH, MUSIC AND SETTINGS
- Downloads appear in Library with their own page, and Downloads settings are available on TV
- Sync works on TV as the host device, since TVs have no camera to scan a code

PLAYBACK AND NOTIFICATIONS
- The music media card shows shuffle and repeat, and like and close stay on older Android, Auto and Wear
- Shuffle from the media card, a headset, a watch or the car now shuffles the queue you see in the app
- The video notification has repeat, and shuffle while a queue is playing
- DeArrow shows a frame from the video when nobody has submitted a thumbnail, as DeArrow itself does, with a switch to turn that off
- DeArrow titles also appear on Shorts cards

FIXES
- Fix a paused video not playing again after the screen was off for a while
- Fix a video stopping for good after a decoder error; it now recovers once, then offers Retry
- Fix a short glitch at the start of the next song when one song plays into the next
- Fix DeArrow looking a video up again every time its card scrolled back into view
- Fix the music player's playback buttons not responding to a keyboard, a remote or TalkBack
- Fix the TV seek bar redrawing every frame while paused
- Fix Open Collective not opening from About on TV
6 changes: 5 additions & 1 deletion app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import io.github.aedev.flow.data.local.LocalDataManager
import io.github.aedev.flow.data.playlist.PlaylistImport
import io.github.aedev.flow.data.playlist.PlaylistTransfer
import io.github.aedev.flow.data.recommendation.FlowNeuroEngine
import io.github.aedev.flow.data.repository.DeArrowRepository
import io.github.aedev.flow.data.repository.OriginalTitleRepository
import io.github.aedev.flow.discord.DiscordPresenceRuntime
import io.github.aedev.flow.notification.NotificationHelper
Expand Down Expand Up @@ -88,6 +89,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var originalTitles: OriginalTitleRepository

@Inject
lateinit var deArrow: DeArrowRepository

@Inject
lateinit var lifecyclePlaybackPreferences: LifecyclePlaybackPreferences

Expand Down Expand Up @@ -263,7 +267,7 @@ class MainActivity : ComponentActivity() {
// Card preferences and watch progress are collected once here. Cards used to
// collect them individually, so a feed of ten opened ten Room observers and
// fifty DataStore collectors.
ProvideVideoCardState(originalTitles) {
ProvideVideoCardState(originalTitles, deArrow) {
Box(
modifier =
Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ class PlayerPreferences(
// DeArrow
val DEARROW_ENABLED = booleanPreferencesKey("dearrow_enabled")
val DEARROW_BADGE_ENABLED = booleanPreferencesKey("dearrow_badge_enabled")
val DEARROW_RANDOM_FRAME = booleanPreferencesKey("dearrow_random_frame")

// Notification preferences
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
Expand Down Expand Up @@ -1732,6 +1733,17 @@ class PlayerPreferences(
}
}

/** DeArrow's own default: a video with no thumbnail submission shows a frame from the video. */
val deArrowRandomFrame: Flow<Boolean> =
context.playerPreferencesDataStore.data
.map { preferences -> preferences[Keys.DEARROW_RANDOM_FRAME] ?: true }

suspend fun setDeArrowRandomFrame(enabled: Boolean) {
context.playerPreferencesDataStore.edit { preferences ->
preferences[Keys.DEARROW_RANDOM_FRAME] = enabled
}
}

// ========== NOTIFICATION PREFERENCES ==========

val notificationsEnabled: Flow<Boolean> =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
package io.github.aedev.flow.data.model

import kotlinx.serialization.Serializable

@Serializable
data class DeArrowTitle(
val title: String = "",
val votes: Int = 0,
val locked: Boolean = false,
val original: Boolean = false
val original: Boolean = false,
)

@Serializable
data class DeArrowThumbnail(
val timestamp: Float? = null,
val thumbnail: String? = null,
val timestamp: Double? = null,
val votes: Int = 0,
val locked: Boolean = false,
val original: Boolean = false
val original: Boolean = false,
)

/** The `/api/branding` answer. Submissions arrive best first, as the server ranks them. */
@Serializable
data class DeArrowContent(
val titles: List<DeArrowTitle> = emptyList(),
val thumbnails: List<DeArrowThumbnail> = emptyList(),
val randomTime: Float? = null,
val videoDuration: Float? = null
val randomTime: Double? = null,
val videoDuration: Double? = null,
)

/**
* The resolved deArrow overrides for a video. Non-null fields should replace the originals.
*/
data class DeArrowResult(
val title: String? = null,
val thumbnailUrl: String? = null
val thumbnailUrl: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package io.github.aedev.flow.data.repository

import io.github.aedev.flow.data.model.DeArrowContent
import io.github.aedev.flow.data.model.DeArrowResult
import okhttp3.HttpUrl.Companion.toHttpUrl

/**
* Turns a branding answer into what a card shows, the way the DeArrow extension does: the server's
* first usable submission wins, a winning "original" keeps YouTube's version, and a video with no
* thumbnail submission shows the frame at the server's random time for it, so every DeArrow user
* sees the same one.
*/
internal object DeArrowBrandingPolicy {
const val THUMBNAIL_URL = "https://dearrow-thumb.ajay.app/api/v1/getThumbnail"

fun resolve(
content: DeArrowContent,
videoId: String,
fallbackDurationSeconds: Int,
useRandomFrame: Boolean,
isLiveOrUpcoming: Boolean,
): DeArrowResult? {
val title =
content.titles
.firstOrNull { it.locked || it.votes >= 0 }
?.takeUnless { it.original }
?.title
?.let(::stripCaseMarkers)
?.takeIf { it.isNotBlank() }

val thumbnail = content.thumbnails.firstOrNull { it.locked || it.votes >= 0 }
val frameTime =
when {
thumbnail != null -> thumbnail.takeUnless { it.original }?.timestamp
useRandomFrame && !isLiveOrUpcoming -> randomFrameTime(content, fallbackDurationSeconds)
else -> null
}
val thumbnailUrl = frameTime?.let { frameUrl(videoId, it) }

return if (title == null && thumbnailUrl == null) null else DeArrowResult(title, thumbnailUrl)
}

private fun randomFrameTime(
content: DeArrowContent,
fallbackDurationSeconds: Int,
): Double? {
val randomTime = content.randomTime ?: return null
val duration =
content.videoDuration?.takeIf { it > 0 }
?: fallbackDurationSeconds.takeIf { it > 0 }?.toDouble()
?: return null
return randomTime * duration
}

fun frameUrl(
videoId: String,
timeSeconds: Double,
): String =
THUMBNAIL_URL
.toHttpUrl()
.newBuilder()
.addQueryParameter("videoID", videoId)
.addQueryParameter("time", timeSeconds.toString())
.build()
.toString()
}

/**
* DeArrow submitters prefix a word with `>` to keep its casing when titles are auto-formatted; the
* marker is formatting metadata, never part of the title.
*/
internal fun stripCaseMarkers(title: String): String =
buildString(title.length) {
var atWordStart = true
title.forEachIndexed { i, c ->
val isMarker =
c == '>' && atWordStart &&
title.getOrNull(i + 1)?.isWhitespace() == false
if (!isMarker) append(c)
atWordStart = c.isWhitespace()
}
}
Loading
Loading