diff --git a/.github/nightly-notes/2026-10-22-fix-watch-later-swipe-music-speed-resume-loudness.md b/.github/nightly-notes/2026-10-22-fix-watch-later-swipe-music-speed-resume-loudness.md new file mode 100644 index 0000000000..160562e8ee --- /dev/null +++ b/.github/nightly-notes/2026-10-22-fix-watch-later-swipe-music-speed-resume-loudness.md @@ -0,0 +1,12 @@ +## New +- Swipe a notification to the right to save its video to Watch Later, or long-press it for more options (#1363) +- Music speed now goes up to 4x with the same speed control as videos (#1346) +- Podcasts and other long tracks can resume where you stopped, from a new switch in Settings, Playback (#1346) + +## Improved +- Videos and Shorts are turned down to YouTube's loudness level, so loud uploads no longer play louder than the rest (#1335) +- Picture-in-picture now starts automatically when you leave a playing video +- Playback settings show whether the equalizer is on (#1335) + +## Fixed +- Stable Voice made every video 6 dB louder; it is replaced by Normalize volume (#1335) diff --git a/app/src/main/java/io/github/aedev/flow/data/engagement/VideoFeedbackUseCase.kt b/app/src/main/java/io/github/aedev/flow/data/engagement/VideoFeedbackUseCase.kt index b57587cb67..b1d6895596 100644 --- a/app/src/main/java/io/github/aedev/flow/data/engagement/VideoFeedbackUseCase.kt +++ b/app/src/main/java/io/github/aedev/flow/data/engagement/VideoFeedbackUseCase.kt @@ -39,10 +39,14 @@ class VideoFeedbackUseCase ) { /** Adds [video] to Watch later, or takes it out; returns whether it is saved afterwards. */ suspend fun toggleWatchLater(video: Video): Boolean { - if (playlistRepository.isInWatchLater(video.id)) { - playlistRepository.removeFromWatchLater(video.id) - return false - } + if (saveToWatchLater(video)) return true + playlistRepository.removeFromWatchLater(video.id) + return false + } + + /** Adds [video] to Watch later unless it is there already; returns whether it was added. */ + suspend fun saveToWatchLater(video: Video): Boolean { + if (playlistRepository.isInWatchLater(video.id)) return false playlistRepository.addToWatchLater(video) videoStats.onAction(LedgerAction.SAVE) runCatching { FlowNeuroEngine.onVideoInteraction(context, video, InteractionType.SAVED) } diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index bf89c879ce..c0c38b4166 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -103,7 +103,7 @@ class PlayerPreferences( val SPONSOR_BLOCK_ENABLED = booleanPreferencesKey("sponsor_block_enabled") val AUTO_PIP_ENABLED = booleanPreferencesKey("auto_pip_enabled") val MANUAL_PIP_BUTTON_ENABLED = booleanPreferencesKey("manual_pip_button_enabled") - val STABLE_VOLUME_ENABLED = booleanPreferencesKey("stable_volume_enabled") + val VIDEO_VOLUME_NORMALIZATION = booleanPreferencesKey("video_volume_normalization") // Buffer settings val MIN_BUFFER_MS = intPreferencesKey("min_buffer_ms") @@ -331,6 +331,7 @@ class PlayerPreferences( val ANIMATED_ARTWORK = booleanPreferencesKey("animated_artwork") val MUSIC_REPEAT_MODE = intPreferencesKey("music_repeat_mode") val PAUSE_MUSIC_WHEN_MUTED = booleanPreferencesKey("pause_music_when_muted") + val MUSIC_RESUME_LONG_TRACKS = booleanPreferencesKey("music_resume_long_tracks") val ANIMATED_ARTWORK_WIFI_ONLY = booleanPreferencesKey("animated_artwork_wifi_only") val AUTO_DOWNLOAD_LIKED_MUSIC = booleanPreferencesKey("auto_download_liked_music") val RECAP_SOURCE = stringPreferencesKey("recap_source") @@ -1688,16 +1689,15 @@ class PlayerPreferences( } } - // Stable Volume - val stableVolumeEnabled: Flow = + /** Videos and Shorts are turned down to YouTube's loudness level, as on youtube.com. */ + val videoVolumeNormalizationEnabled: Flow = context.playerPreferencesDataStore.data - .map { preferences -> - preferences[Keys.STABLE_VOLUME_ENABLED] ?: false - } + .map { preferences -> preferences[Keys.VIDEO_VOLUME_NORMALIZATION] ?: true } + .distinctUntilChanged() - suspend fun setStableVolumeEnabled(enabled: Boolean) { + suspend fun setVideoVolumeNormalizationEnabled(enabled: Boolean) { context.playerPreferencesDataStore.edit { preferences -> - preferences[Keys.STABLE_VOLUME_ENABLED] = enabled + preferences[Keys.VIDEO_VOLUME_NORMALIZATION] = enabled } } @@ -2208,6 +2208,18 @@ class PlayerPreferences( } } + /** Podcasts and other long tracks pick up where they stopped when played again. */ + val musicResumeLongTracks: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.MUSIC_RESUME_LONG_TRACKS] ?: false } + .distinctUntilChanged() + + suspend fun setMusicResumeLongTracks(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.MUSIC_RESUME_LONG_TRACKS] = enabled + } + } + /** The music player's repeat mode, as Media3's own Player.REPEAT_MODE_* value. */ val musicRepeatMode: Flow = context.playerPreferencesDataStore.data @@ -2577,7 +2589,7 @@ class PlayerPreferences( val autoPipEnabled: Flow = context.playerPreferencesDataStore.data .map { preferences -> - preferences[Keys.AUTO_PIP_ENABLED] ?: false + preferences[Keys.AUTO_PIP_ENABLED] ?: true } suspend fun setAutoPipEnabled(enabled: Boolean) { diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlaylistRepository.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlaylistRepository.kt index aafa330e94..492af07cab 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlaylistRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlaylistRepository.kt @@ -39,7 +39,7 @@ class PlaylistRepository } suspend fun updateVideoMetadata(video: Video) { - videoDao.upsertMetadata(listOf(normalizedEntity(video))) + videoDao.mergeMetadata(listOf(normalizedEntity(video))) } private fun normalizedEntity(video: Video): VideoEntity { diff --git a/app/src/main/java/io/github/aedev/flow/data/local/ViewHistory.kt b/app/src/main/java/io/github/aedev/flow/data/local/ViewHistory.kt index aeaac462f6..fc56e0ec84 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/ViewHistory.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/ViewHistory.kt @@ -133,6 +133,43 @@ class ViewHistory private constructor( suspend fun getSavedPosition(videoId: String): Long = dao.getPosition(videoId) ?: 0L + suspend fun getSavedDuration(videoId: String): Long = dao.getDuration(videoId) ?: 0L + + /** Records that a song started without moving the place a long track was left at. */ + suspend fun touchMusicEntry( + videoId: String, + duration: Long, + title: String, + thumbnailUrl: String, + channelName: String, + channelId: String, + isLocal: Boolean = LocalMediaIds.isLocal(videoId), + ) = savePlaybackPosition( + videoId = videoId, + position = dao.getPosition(videoId) ?: 0L, + duration = duration, + title = title, + thumbnailUrl = thumbnailUrl, + channelName = channelName, + channelId = channelId, + isMusic = true, + isLocal = isLocal, + ) + + /** Where a long track stopped. A row history already holds keeps its title, artist and time. */ + suspend fun saveMusicPosition( + videoId: String, + position: Long, + duration: Long, + title: String, + thumbnailUrl: String, + channelName: String, + ) { + if (PlayerPreferences(context).isWatchHistorySavingBlocked()) return + if (dao.updateProgress(videoId, position, duration) > 0) return + savePlaybackPosition(videoId, position, duration, title, thumbnailUrl, channelName, isMusic = true) + } + /** * Create-or-touch a history entry **without** overwriting an already-saved * playback position. Call this on video open so the video appears in history diff --git a/app/src/main/java/io/github/aedev/flow/data/local/dao/CacheDao.kt b/app/src/main/java/io/github/aedev/flow/data/local/dao/CacheDao.kt index 9424e6060c..cac96716b1 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/dao/CacheDao.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/dao/CacheDao.kt @@ -42,6 +42,9 @@ interface CacheDao { @Query("SELECT * FROM subscription_feed_cache WHERE channelId IN (:channelIds)") suspend fun getSubscriptionFeedForChannels(channelIds: List): List + @Query("SELECT * FROM subscription_feed_cache WHERE videoId = :videoId") + suspend fun getSubscriptionFeedRow(videoId: String): SubscriptionFeedEntity? + @Query("SELECT DISTINCT channelId, channelName FROM subscription_feed_cache WHERE channelId IN (:channelIds) AND channelName != ''") suspend fun subscriptionFeedChannelNames(channelIds: List): List diff --git a/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt b/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt index 332d734afc..9530955faf 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/dao/VideoDao.kt @@ -118,27 +118,6 @@ interface VideoDao { } } - /** Inserts or refreshes many videos as one change, so a list observing them updates once. */ - @Transaction - suspend fun upsertMetadata(entities: List) { - entities.forEach { entity -> - insertVideoOrIgnore(entity) - updateVideoMetadata( - id = entity.id, - title = entity.title, - channelName = entity.channelName, - channelId = entity.channelId, - thumbnailUrl = entity.thumbnailUrl, - duration = entity.duration, - viewCount = entity.viewCount, - uploadDate = entity.uploadDate, - timestamp = entity.timestamp, - description = entity.description, - channelThumbnailUrl = entity.channelThumbnailUrl, - ) - } - } - @Query("SELECT * FROM videos WHERE id = :id") suspend fun getVideo(id: String): VideoEntity? diff --git a/app/src/main/java/io/github/aedev/flow/data/local/dao/WatchHistoryDao.kt b/app/src/main/java/io/github/aedev/flow/data/local/dao/WatchHistoryDao.kt index e8648db7c4..ee5b7dacbc 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/dao/WatchHistoryDao.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/dao/WatchHistoryDao.kt @@ -98,6 +98,14 @@ interface WatchHistoryDao { durationMs: Long, ) + /** Moves only the saved place; 0 means [videoId] has no row yet. */ + @Query("UPDATE watch_history SET position = :position, duration = :duration WHERE videoId = :videoId") + suspend fun updateProgress( + videoId: String, + position: Long, + duration: Long, + ): Int + /** Drops saved progress without deleting the history row, so the card no longer reads as watched. */ @Query("UPDATE watch_history SET position = 0 WHERE videoId = :videoId") suspend fun clearProgress(videoId: String) diff --git a/app/src/main/java/io/github/aedev/flow/data/shorts/ShortsStreamResolver.kt b/app/src/main/java/io/github/aedev/flow/data/shorts/ShortsStreamResolver.kt index ebdab1fc4c..61d4b02dd2 100644 --- a/app/src/main/java/io/github/aedev/flow/data/shorts/ShortsStreamResolver.kt +++ b/app/src/main/java/io/github/aedev/flow/data/shorts/ShortsStreamResolver.kt @@ -3,6 +3,7 @@ package io.github.aedev.flow.data.shorts import android.util.Log import io.github.aedev.flow.data.local.PlayerPreferences import io.github.aedev.flow.innertube.models.response.PlayerResponse +import io.github.aedev.flow.player.audio.LoudnessGain import io.github.aedev.flow.player.shorts.ShortsStartupTrace import io.github.aedev.flow.player.stream.InFlightRequestCoalescer import io.github.aedev.flow.player.stream.InnerTubeVideoStreamExtractor @@ -36,6 +37,8 @@ data class ShortPlaybackStreams( val videoDashManifest: String? = null, val audioDashManifest: String? = null, val details: ShortDetails? = null, + /** YouTube's loudness for the reel, in dB, for volume normalization. */ + val loudnessGainDb: Float? = null, ) data class ShortVideoQuality( @@ -209,6 +212,7 @@ class ShortsStreamResolver internal constructor( videoDashManifest = ShortsDashManifest.forVideo(video, videoUrl, durationMs), audioDashManifest = audio?.url?.let { ShortsDashManifest.forAudio(audio, it, durationMs) }, details = playerResponse.videoDetails?.toShortDetails(durationMs), + loudnessGainDb = LoudnessGain.gainDb(playerResponse.playerConfig?.audioConfig, audio?.loudnessDb), ) } diff --git a/app/src/main/java/io/github/aedev/flow/data/subscriptions/SubscriptionFeedRepository.kt b/app/src/main/java/io/github/aedev/flow/data/subscriptions/SubscriptionFeedRepository.kt index 0eb8566701..151e7a6862 100644 --- a/app/src/main/java/io/github/aedev/flow/data/subscriptions/SubscriptionFeedRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/subscriptions/SubscriptionFeedRepository.kt @@ -328,6 +328,10 @@ class SubscriptionFeedRepository Log.d(TAG, "Seeded ${rows.size} row(s) for $channelId from the notification check") } + /** The feed's stored copy of [videoId], or null when the feed has never seen it. */ + suspend fun cachedVideo(videoId: String): Video? = + withContext(PerformanceDispatcher.diskIO) { cacheDao.getSubscriptionFeedRow(videoId)?.toVideo() } + /** Writes back metadata the on-demand player lookup resolved for already-cached rows. */ suspend fun updateEnrichedMetadata(videos: Collection