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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1688,16 +1689,15 @@ class PlayerPreferences(
}
}

// Stable Volume
val stableVolumeEnabled: Flow<Boolean> =
/** Videos and Shorts are turned down to YouTube's loudness level, as on youtube.com. */
val videoVolumeNormalizationEnabled: Flow<Boolean> =
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
}
}

Expand Down Expand Up @@ -2208,6 +2208,18 @@ class PlayerPreferences(
}
}

/** Podcasts and other long tracks pick up where they stopped when played again. */
val musicResumeLongTracks: Flow<Boolean> =
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<Int> =
context.playerPreferencesDataStore.data
Expand Down Expand Up @@ -2577,7 +2589,7 @@ class PlayerPreferences(
val autoPipEnabled: Flow<Boolean> =
context.playerPreferencesDataStore.data
.map { preferences ->
preferences[Keys.AUTO_PIP_ENABLED] ?: false
preferences[Keys.AUTO_PIP_ENABLED] ?: true
}

suspend fun setAutoPipEnabled(enabled: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ interface CacheDao {
@Query("SELECT * FROM subscription_feed_cache WHERE channelId IN (:channelIds)")
suspend fun getSubscriptionFeedForChannels(channelIds: List<String>): List<SubscriptionFeedEntity>

@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<String>): List<ChannelNameRow>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VideoEntity>) {
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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Video>) {
if (videos.isEmpty()) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1065,19 +1065,26 @@ object EnhancedMusicPlayerManager {
}
}

fun setPlaybackSpeed(speed: Float) {
/** A slider passes [persist] false while dragging and saves once on release. */
fun setPlaybackSpeed(
speed: Float,
persist: Boolean = true,
) {
_playbackSpeed.value = speed
scope.launch { audioSettingsPersistence?.saveSpeed(speed) }
if (persist) scope.launch { audioSettingsPersistence?.saveSpeed(speed) }

player?.let { p ->
val currentPitch = p.playbackParameters.pitch
p.playbackParameters = PlaybackParameters(speed, currentPitch)
}
}

fun setPlaybackPitch(semitones: Float) {
fun setPlaybackPitch(
semitones: Float,
persist: Boolean = true,
) {
_playbackPitch.value = semitones
scope.launch { audioSettingsPersistence?.savePitch(semitones) }
if (persist) scope.launch { audioSettingsPersistence?.savePitch(semitones) }

player?.let { p ->
val pitch = 2.0.pow(semitones.toDouble() / 12.0).toFloat()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -780,12 +780,20 @@ class EnhancedPlayerManager private constructor() {
audioFeaturesManager?.setVolumeBoost(player, volume)
}

/** YouTube's loudness for [videoId], in dB; null when the stream carries none. */
fun setLoudness(
videoId: String,
gainDb: Float?,
) {
audioFeaturesManager?.setLoudness(videoId, gainDb)
}

private fun observePreferences(
context: Context,
built: CoroutineScope,
) {
audioFeaturesManager?.observeSkipSilencePreference(context, built)
audioFeaturesManager?.observeStableVolumePreference(context, built)
audioFeaturesManager?.observeVolumeNormalizationPreference(context, built)

val prefs = PlayerPreferences(context)
built.launch {
Expand Down Expand Up @@ -948,6 +956,7 @@ class EnhancedPlayerManager private constructor() {
reason: Int,
) {
autoNextLog("onMediaItemTransition reason=$reason mediaId=${mediaItem?.mediaId}")
audioFeaturesManager?.onMediaItemChanged()
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
) {
Expand Down Expand Up @@ -2905,7 +2914,7 @@ class EnhancedPlayerManager private constructor() {

fun toggleSkipSilence(isEnabled: Boolean) = audioFeaturesManager?.toggleSkipSilence(isEnabled, appContext)

fun toggleStableVolume(isEnabled: Boolean) = audioFeaturesManager?.toggleStableVolume(isEnabled, appContext)
fun toggleVolumeNormalization(isEnabled: Boolean) = audioFeaturesManager?.toggleVolumeNormalization(isEnabled, appContext)

fun toggleSponsorBlock(isEnabled: Boolean) {
sponsorBlockHandler?.setEnabled(isEnabled)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.github.aedev.flow.player

/** Podcasts, audiobooks and long mixes pick up where they stopped; songs always start over. */
object MusicResumePolicy {
const val MIN_DURATION_MS = 10 * 60 * 1000L

fun isLongTrack(durationMs: Long): Boolean = durationMs >= MIN_DURATION_MS

fun startPosition(
savedPosition: Long,
durationMs: Long,
): Long =
PlaybackResumePolicy.resolveStartPosition(
savedPosition = savedPosition,
durationMs = durationMs,
resumeAllowed = isLongTrack(durationMs),
)
}
Loading
Loading