diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/mappers/ToVideoState.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/mappers/ToVideoState.kt index fcfc760d85..b60996cde4 100644 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/mappers/ToVideoState.kt +++ b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/mappers/ToVideoState.kt @@ -10,7 +10,6 @@ fun MediumStateEntity.toVideoState(): VideoState { position = playbackPosition.takeIf { it != 0L }, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, - playbackSpeed = playbackSpeed, externalSubs = UriListConverter.fromStringToList(externalSubs), externalAudio = UriListConverter.fromStringToList(externalAudio), videoScale = videoScale, diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/models/VideoState.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/models/VideoState.kt index 071e9f25d8..4a8b1a8313 100644 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/models/VideoState.kt +++ b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/models/VideoState.kt @@ -7,7 +7,6 @@ data class VideoState( val position: Long?, val audioTrackIndex: Int?, val subtitleTrackIndex: Int?, - val playbackSpeed: Float?, val externalSubs: List, val externalAudio: List = emptyList(), val videoScale: Float, diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalMediaRepository.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalMediaRepository.kt index eb70ab29ba..2ffb958d49 100644 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalMediaRepository.kt +++ b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalMediaRepository.kt @@ -137,10 +137,6 @@ class LocalMediaRepository( mediumStateDao.update(uri) { it.copy(playbackPosition = position) } } - override suspend fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float) { - mediumStateDao.update(uri) { it.copy(playbackSpeed = playbackSpeed) } - } - override suspend fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int) { mediumStateDao.update(uri) { it.copy(audioTrackIndex = audioTrackIndex) } } diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/MediaRepository.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/MediaRepository.kt index 49059a9a03..1e67a1f561 100644 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/MediaRepository.kt +++ b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/MediaRepository.kt @@ -56,7 +56,6 @@ interface MediaRepository { suspend fun clearPlaybackHistory() suspend fun updateMediumLastPlayedTime(uri: String, lastPlayedTime: Long, duration: Long? = null) suspend fun updateMediumPosition(uri: String, position: Long) - suspend fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float) suspend fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int) suspend fun updateMediumSubtitleTrack(uri: String, subtitleTrackIndex: Int) suspend fun updateMediumZoom(uri: String, zoom: Float) diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/fake/FakeMediaRepository.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/fake/FakeMediaRepository.kt index 1abbbb9e1e..6405787a47 100644 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/fake/FakeMediaRepository.kt +++ b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/fake/FakeMediaRepository.kt @@ -69,9 +69,6 @@ class FakeMediaRepository : MediaRepository { override suspend fun updateMediumPosition(uri: String, position: Long) { } - override suspend fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float) { - } - override suspend fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int) { } diff --git a/core/database/schemas/dev.anilbeesetti.nextplayer.core.database.MediaDatabase/13.json b/core/database/schemas/dev.anilbeesetti.nextplayer.core.database.MediaDatabase/13.json new file mode 100644 index 0000000000..16e68bed34 --- /dev/null +++ b/core/database/schemas/dev.anilbeesetti.nextplayer.core.database.MediaDatabase/13.json @@ -0,0 +1,412 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "d2575f70e39d4ab87eceacaa9e14ab9a", + "entities": [ + { + "tableName": "media_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `playback_position` INTEGER NOT NULL, `audio_track_index` INTEGER, `subtitle_track_index` INTEGER, `last_played_time` INTEGER, `duration` INTEGER, `external_subs` TEXT NOT NULL, `external_audio` TEXT NOT NULL DEFAULT '', `video_scale` REAL NOT NULL, `subtitle_delay` INTEGER NOT NULL, `subtitle_speed` REAL NOT NULL, PRIMARY KEY(`uri`))", + "fields": [ + { + "fieldPath": "uriString", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playbackPosition", + "columnName": "playback_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioTrackIndex", + "columnName": "audio_track_index", + "affinity": "INTEGER" + }, + { + "fieldPath": "subtitleTrackIndex", + "columnName": "subtitle_track_index", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastPlayedTime", + "columnName": "last_played_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalSubs", + "columnName": "external_subs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalAudio", + "columnName": "external_audio", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "videoScale", + "columnName": "video_scale", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "subtitleDelayMilliseconds", + "columnName": "subtitle_delay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleSpeed", + "columnName": "subtitle_speed", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uri" + ] + }, + "indices": [ + { + "name": "index_media_state_uri", + "unique": true, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_media_state_uri` ON `${TABLE_NAME}` (`uri`)" + } + ] + }, + { + "tableName": "hidden_video", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `vault_path` TEXT NOT NULL, `original_path` TEXT NOT NULL, `display_name` TEXT NOT NULL, `duration` INTEGER NOT NULL, `size` INTEGER NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `hidden_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultPath", + "columnName": "vault_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalPath", + "columnName": "original_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hiddenAt", + "columnName": "hidden_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_hidden_video_vault_path", + "unique": true, + "columnNames": [ + "vault_path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_hidden_video_vault_path` ON `${TABLE_NAME}` (`vault_path`)" + } + ] + }, + { + "tableName": "network_connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER, `path` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `use_https` INTEGER NOT NULL, `authentication` TEXT NOT NULL DEFAULT 'PASSWORD', `private_key_file_name` TEXT NOT NULL DEFAULT '', `private_key_passphrase` TEXT NOT NULL DEFAULT '', `host_key_fingerprint` TEXT NOT NULL DEFAULT '', `created_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useHttps", + "columnName": "use_https", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authentication", + "columnName": "authentication", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'PASSWORD'" + }, + { + "fieldPath": "privateKeyFileName", + "columnName": "private_key_file_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "privateKeyPassphrase", + "columnName": "private_key_passphrase", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "hostKeyFingerprint", + "columnName": "host_key_fingerprint", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `source` TEXT, `created_at` INTEGER NOT NULL, `last_refreshed_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRefreshedAt", + "columnName": "last_refreshed_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `position` INTEGER NOT NULL, `title` TEXT, `tvg_logo` TEXT, `duration` INTEGER NOT NULL, `group_title` TEXT, `last_played_at` INTEGER, PRIMARY KEY(`playlist_id`, `uri`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "tvgLogo", + "columnName": "tvg_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupTitle", + "columnName": "group_title", + "affinity": "TEXT" + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "uri" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + }, + { + "name": "index_playlist_item_playlist_id_position", + "unique": true, + "columnNames": [ + "playlist_id", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_item_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd2575f70e39d4ab87eceacaa9e14ab9a')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidTest/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabaseMigrationTest.kt b/core/database/src/androidTest/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabaseMigrationTest.kt index 38b0035f8a..4a84867bf0 100644 --- a/core/database/src/androidTest/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabaseMigrationTest.kt +++ b/core/database/src/androidTest/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabaseMigrationTest.kt @@ -4,6 +4,7 @@ import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -85,6 +86,55 @@ class MediaDatabaseMigrationTest { } } + @Test + fun migrate12To13_removesVideoSpeedAndPreservesOtherState() { + helper.createDatabase("speed-migration", 12).apply { + execSQL( + """ + INSERT INTO media_state ( + uri, playback_position, audio_track_index, subtitle_track_index, playback_speed, + last_played_time, duration, external_subs, external_audio, video_scale, + subtitle_delay, subtitle_speed + ) VALUES ( + 'content://video/one', 12345, 2, 3, 1.75, 67890, 90000, + 'file:///captions.srt', 'file:///audio.aac', 1.5, 250, 0.75 + ) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO media_state ( + uri, playback_position, external_subs, video_scale, subtitle_delay, subtitle_speed + ) VALUES ('content://video/two', 0, '', 1, 0, 1) + """.trimIndent(), + ) + close() + } + helper.runMigrationsAndValidate("speed-migration", 13, true, MediaDatabase.MIGRATION_12_13).use { db -> + db.query("SELECT * FROM media_state ORDER BY uri").use { cursor -> + assertEquals(2, cursor.count) + assertEquals(-1, cursor.getColumnIndex("playback_speed")) + check(cursor.moveToFirst()) + assertEquals("content://video/one", cursor.getString(cursor.getColumnIndexOrThrow("uri"))) + assertEquals(12345L, cursor.getLong(cursor.getColumnIndexOrThrow("playback_position"))) + assertEquals(2, cursor.getInt(cursor.getColumnIndexOrThrow("audio_track_index"))) + assertEquals(3, cursor.getInt(cursor.getColumnIndexOrThrow("subtitle_track_index"))) + assertEquals(67890L, cursor.getLong(cursor.getColumnIndexOrThrow("last_played_time"))) + assertEquals(90000L, cursor.getLong(cursor.getColumnIndexOrThrow("duration"))) + assertEquals("file:///captions.srt", cursor.getString(cursor.getColumnIndexOrThrow("external_subs"))) + assertEquals("file:///audio.aac", cursor.getString(cursor.getColumnIndexOrThrow("external_audio"))) + assertEquals(1.5f, cursor.getFloat(cursor.getColumnIndexOrThrow("video_scale"))) + assertEquals(250L, cursor.getLong(cursor.getColumnIndexOrThrow("subtitle_delay"))) + assertEquals(0.75f, cursor.getFloat(cursor.getColumnIndexOrThrow("subtitle_speed"))) + check(cursor.moveToNext()) + assertEquals("content://video/two", cursor.getString(cursor.getColumnIndexOrThrow("uri"))) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("audio_track_index"))) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("last_played_time"))) + assertEquals("", cursor.getString(cursor.getColumnIndexOrThrow("external_audio"))) + } + } + } + private companion object { const val TEST_DB = "migration-test" } diff --git a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/DatabaseModule.kt b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/DatabaseModule.kt index 1078012a0e..2aad42b00c 100644 --- a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/DatabaseModule.kt +++ b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/DatabaseModule.kt @@ -28,6 +28,7 @@ class DatabaseModule { MediaDatabase.MIGRATION_9_10, MediaDatabase.MIGRATION_10_11, MediaDatabase.MIGRATION_11_12, + MediaDatabase.MIGRATION_12_13, ) fallbackToDestructiveMigration(false) }.build() diff --git a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabase.kt b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabase.kt index c992bca9cb..9da2f5f969 100644 --- a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabase.kt +++ b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/MediaDatabase.kt @@ -22,7 +22,7 @@ import dev.anilbeesetti.nextplayer.core.database.entities.PlaylistItemEntity PlaylistEntity::class, PlaylistItemEntity::class, ], - version = 12, + version = 13, exportSchema = true, ) abstract class MediaDatabase : RoomDatabase() { @@ -329,5 +329,46 @@ abstract class MediaDatabase : RoomDatabase() { db.execSQL("ALTER TABLE `media_state` ADD COLUMN `duration` INTEGER") } } + + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + // Rebuild the table because older Android SQLite versions cannot drop columns. + db.execSQL( + """ + CREATE TABLE `media_state_new` ( + `uri` TEXT NOT NULL, + `playback_position` INTEGER NOT NULL, + `audio_track_index` INTEGER, + `subtitle_track_index` INTEGER, + `last_played_time` INTEGER, + `duration` INTEGER, + `external_subs` TEXT NOT NULL, + `external_audio` TEXT NOT NULL DEFAULT '', + `video_scale` REAL NOT NULL, + `subtitle_delay` INTEGER NOT NULL, + `subtitle_speed` REAL NOT NULL, + PRIMARY KEY(`uri`) + ) + """, + ) + db.execSQL( + """ + INSERT INTO `media_state_new` ( + `uri`, `playback_position`, `audio_track_index`, `subtitle_track_index`, + `last_played_time`, `duration`, `external_subs`, `external_audio`, + `video_scale`, `subtitle_delay`, `subtitle_speed` + ) + SELECT + `uri`, `playback_position`, `audio_track_index`, `subtitle_track_index`, + `last_played_time`, `duration`, `external_subs`, `external_audio`, + `video_scale`, `subtitle_delay`, `subtitle_speed` + FROM `media_state` + """, + ) + db.execSQL("DROP TABLE `media_state`") + db.execSQL("ALTER TABLE `media_state_new` RENAME TO `media_state`") + db.execSQL("CREATE UNIQUE INDEX `index_media_state_uri` ON `media_state` (`uri`)") + } + } } } diff --git a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/entities/MediumStateEntity.kt b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/entities/MediumStateEntity.kt index b71fef84c4..6f03df770f 100644 --- a/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/entities/MediumStateEntity.kt +++ b/core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/entities/MediumStateEntity.kt @@ -21,8 +21,6 @@ data class MediumStateEntity( val audioTrackIndex: Int? = null, @ColumnInfo(name = "subtitle_track_index") val subtitleTrackIndex: Int? = null, - @ColumnInfo(name = "playback_speed") - val playbackSpeed: Float? = null, @ColumnInfo(name = "last_played_time") val lastPlayedTime: Long? = null, @ColumnInfo(name = "duration") diff --git a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/MediaItem.kt b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/MediaItem.kt index 2409f48a94..523abcdb66 100644 --- a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/MediaItem.kt +++ b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/MediaItem.kt @@ -23,7 +23,6 @@ fun MediaItem.withExternalAudio(uris: List): MediaItem = buildUpon() ).build() private const val MEDIA_METADATA_POSITION_KEY = "media_metadata_position" -private const val MEDIA_METADATA_PLAYBACK_SPEED_KEY = "media_metadata_playback_speed" private const val MEDIA_METADATA_AUDIO_TRACK_INDEX_KEY = "audio_track_index" private const val MEDIA_METADATA_SUBTITLE_TRACK_INDEX_KEY = "subtitle_track_index" private const val MEDIA_METADATA_VIDEO_ZOOM_KEY = "media_metadata_video_zoom" @@ -35,7 +34,6 @@ private const val MEDIA_METADATA_AUDIO_DECODER_MODE_KEY = "media_metadata_audio_ private fun Bundle.setExtras( positionMs: Long?, videoScale: Float?, - playbackSpeed: Float?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, subtitleDelayMilliseconds: Long? = null, @@ -45,7 +43,6 @@ private fun Bundle.setExtras( ) = apply { positionMs?.let { putLong(MEDIA_METADATA_POSITION_KEY, it) } videoScale?.let { putFloat(MEDIA_METADATA_VIDEO_ZOOM_KEY, it) } - playbackSpeed?.let { putFloat(MEDIA_METADATA_PLAYBACK_SPEED_KEY, it) } audioTrackIndex?.let { putInt(MEDIA_METADATA_AUDIO_TRACK_INDEX_KEY, it) } subtitleTrackIndex?.let { putInt(MEDIA_METADATA_SUBTITLE_TRACK_INDEX_KEY, it) } subtitleDelayMilliseconds?.let { putLong(MEDIA_METADATA_SUBTITLE_DELAY_KEY, it) } @@ -57,7 +54,6 @@ private fun Bundle.setExtras( fun MediaMetadata.Builder.setExtras( positionMs: Long? = null, videoScale: Float? = null, - playbackSpeed: Float? = null, audioTrackIndex: Int? = null, subtitleTrackIndex: Int? = null, subtitleDelayMilliseconds: Long? = null, @@ -68,7 +64,6 @@ fun MediaMetadata.Builder.setExtras( Bundle().setExtras( positionMs = positionMs, videoScale = videoScale, - playbackSpeed = playbackSpeed, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, subtitleDelayMilliseconds = subtitleDelayMilliseconds, @@ -84,12 +79,6 @@ val MediaMetadata.positionMs: Long? .takeIf { containsKey(MEDIA_METADATA_POSITION_KEY) } } -val MediaMetadata.playbackSpeed: Float? - get() = extras?.run { - getFloat(MEDIA_METADATA_PLAYBACK_SPEED_KEY) - .takeIf { containsKey(MEDIA_METADATA_PLAYBACK_SPEED_KEY) } - } - val MediaMetadata.audioTrackIndex: Int? get() = extras?.run { getInt(MEDIA_METADATA_AUDIO_TRACK_INDEX_KEY) @@ -130,7 +119,6 @@ fun MediaItem.copy( positionMs: Long? = this.mediaMetadata.positionMs, durationMs: Long? = this.mediaMetadata.durationMs, videoZoom: Float? = this.mediaMetadata.videoZoom, - playbackSpeed: Float? = this.mediaMetadata.playbackSpeed, audioTrackIndex: Int? = this.mediaMetadata.audioTrackIndex, subtitleTrackIndex: Int? = this.mediaMetadata.subtitleTrackIndex, subtitleDelayMilliseconds: Long? = this.mediaMetadata.subtitleDelayMilliseconds, @@ -144,7 +132,6 @@ fun MediaItem.copy( (mediaMetadata.extras?.let(::Bundle) ?: Bundle()).setExtras( positionMs = positionMs, videoScale = videoZoom, - playbackSpeed = playbackSpeed, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, subtitleDelayMilliseconds = subtitleDelayMilliseconds, diff --git a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/service/PlayerService.kt b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/service/PlayerService.kt index 2979e3262a..33a4c6fa83 100644 --- a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/service/PlayerService.kt +++ b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/service/PlayerService.kt @@ -14,7 +14,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION import androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE @@ -61,7 +60,6 @@ import dev.anilbeesetti.nextplayer.feature.player.extensions.copy import dev.anilbeesetti.nextplayer.feature.player.extensions.externalAudio import dev.anilbeesetti.nextplayer.feature.player.extensions.externalAudioIndex import dev.anilbeesetti.nextplayer.feature.player.extensions.getManuallySelectedTrackIndex -import dev.anilbeesetti.nextplayer.feature.player.extensions.playbackSpeed import dev.anilbeesetti.nextplayer.feature.player.extensions.positionMs import dev.anilbeesetti.nextplayer.feature.player.extensions.setExtras import dev.anilbeesetti.nextplayer.feature.player.extensions.setIsScrubbingModeEnabled @@ -210,7 +208,6 @@ class PlayerService : MediaSessionService() { loadArtworkForCurrentMediaItem() mediaItem?.mediaMetadata?.let { metadata -> mediaSession?.player?.run { - setPlaybackSpeed(metadata.playbackSpeed ?: playerPreferences.defaultPlaybackSpeed) playerSpecificSubtitleDelayMilliseconds = metadata.subtitleDelayMilliseconds ?: 0L playerSpecificSubtitleSpeed = metadata.subtitleSpeed ?: 1f } @@ -325,36 +322,17 @@ class PlayerService : MediaSessionService() { ) } - override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { - super.onPlaybackParametersChanged(playbackParameters) - val player = mediaSession?.player ?: return - val currentMediaItem = player.currentMediaItem ?: return - val playbackSpeed = playbackParameters.speed - - serviceScope.launch { - mediaRepository.updateMediumPlaybackSpeed( - uri = currentMediaItem.mediaId, - playbackSpeed = playbackSpeed, - ) - } - player.replaceMediaItem( - player.currentMediaItemIndex, - currentMediaItem.copy(playbackSpeed = playbackSpeed), - ) - } - override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) val player = mediaSession?.player - val shouldResetPlaybackParameters = playbackState == Player.STATE_ENDED || + val shouldResetTrackSelectionParameters = playbackState == Player.STATE_ENDED || ( playbackState == Player.STATE_IDLE && player?.mediaItemCount == 0 ) - if (shouldResetPlaybackParameters) { + if (shouldResetTrackSelectionParameters) { mediaSession?.player?.trackSelectionParameters = TrackSelectionParameters.DEFAULT - mediaSession?.player?.setPlaybackSpeed(playerPreferences.defaultPlaybackSpeed) } } @@ -737,6 +715,7 @@ class PlayerService : MediaSessionService() { .setHandleAudioBecomingNoisy(playerPreferences.pauseOnHeadsetDisconnect) .build() .also { + it.setPlaybackSpeed(playerPreferences.defaultPlaybackSpeed) it.addListener(playbackStateListener) it.pauseAtEndOfMediaItems = !playerPreferences.autoplay it.repeatMode = when (playerPreferences.loopMode) { @@ -869,7 +848,6 @@ class PlayerService : MediaSessionService() { val title = mediaItem.mediaMetadata.title ?: video?.nameWithExtension ?: getFilenameFromUri(uri) val positionMs = mediaItem.mediaMetadata.positionMs ?: videoState?.position val videoScale = mediaItem.mediaMetadata.videoZoom ?: videoState?.videoScale - val playbackSpeed = mediaItem.mediaMetadata.playbackSpeed ?: videoState?.playbackSpeed val audioTrackIndex = mediaItem.mediaMetadata.audioTrackIndex ?: videoState?.audioTrackIndex val subtitleTrackIndex = mediaItem.mediaMetadata.subtitleTrackIndex ?: videoState?.subtitleTrackIndex val subtitleDelay = mediaItem.mediaMetadata.subtitleDelayMilliseconds ?: videoState?.subtitleDelayMilliseconds @@ -884,7 +862,6 @@ class PlayerService : MediaSessionService() { setExtras( positionMs = positionMs, videoScale = videoScale, - playbackSpeed = playbackSpeed, audioTrackIndex = audioTrackIndex.takeIf { externalAudio == savedAudio }, subtitleTrackIndex = subtitleTrackIndex, subtitleDelayMilliseconds = subtitleDelay, diff --git a/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/extensions/ExternalAudioTest.kt b/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/extensions/ExternalAudioTest.kt index ddd7710cbe..5707d386cc 100644 --- a/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/extensions/ExternalAudioTest.kt +++ b/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/extensions/ExternalAudioTest.kt @@ -36,7 +36,6 @@ class ExternalAudioTest { .copy( positionMs = 12000, audioTrackIndex = 2, - playbackSpeed = 1.5f, videoDecoderMode = DecoderMode.HARDWARE, audioDecoderMode = DecoderMode.SOFTWARE, ) diff --git a/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/service/PlaybackSpeedSessionTest.kt b/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/service/PlaybackSpeedSessionTest.kt new file mode 100644 index 0000000000..3ec5584ccd --- /dev/null +++ b/feature/player/src/test/java/dev/anilbeesetti/nextplayer/feature/player/service/PlaybackSpeedSessionTest.kt @@ -0,0 +1,142 @@ +package dev.anilbeesetti.nextplayer.feature.player.service + +import android.os.Bundle +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaSession +import dev.anilbeesetti.nextplayer.core.data.repository.MediaRepository +import dev.anilbeesetti.nextplayer.core.data.repository.PreferencesRepository +import dev.anilbeesetti.nextplayer.core.data.repository.fake.FakeMediaRepository +import dev.anilbeesetti.nextplayer.core.data.repository.fake.FakePreferencesRepository +import dev.anilbeesetti.nextplayer.core.media.network.NetworkClientFactory +import dev.anilbeesetti.nextplayer.core.media.network.NetworkConnectionResolver +import dev.anilbeesetti.nextplayer.core.media.network.datasource.NetworkSessions +import dev.anilbeesetti.nextplayer.core.media.network.datasource.NextDataSourceFactory +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class PlaybackSpeedSessionTest { + private val preferences = FakePreferencesRepository() + + @Before + fun setUp() { + runBlocking { preferences.updatePlayerPreferences { it.copy(defaultPlaybackSpeed = 1.25f) } } + startKoin { + modules( + module { + single { preferences } + single { FakeMediaRepository() } + single { + NextDataSourceFactory( + RuntimeEnvironment.getApplication(), + NetworkSessions( + NetworkConnectionResolver { null }, + NetworkClientFactory { error("No network playback in this test") }, + ), + ) + } + }, + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun sessionStartsAtPreferenceAndIgnoresLegacyVideoSpeed() = withSession { _, player -> + assertEquals(1.25f, player.playbackParameters.speed) + player.setMediaItems(listOf(video("first", 0.5f), video("second", 3f))) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.25f, player.playbackParameters.speed) + player.seekToNextMediaItem() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.25f, player.playbackParameters.speed) + } + + @Test + fun changedSpeedSurvivesNavigationRepeatAndQueueReplacement() = withSession { service, player -> + player.setMediaItems(listOf(video("first", 0.5f), video("second", 3f))) + shadowOf(Looper.getMainLooper()).idle() + player.setPlaybackSpeed(1.75f) + player.seekToNextMediaItem() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.75f, player.playbackParameters.speed) + player.seekToPreviousMediaItem() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.75f, player.playbackParameters.speed) + + val listener = PlayerService::class.java.getDeclaredField("playbackStateListener") + .apply { isAccessible = true }.get(service) as Player.Listener + listener.onMediaItemTransition(player.currentMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) + listener.onMediaItemTransition(player.currentMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) + listener.onPlaybackStateChanged(Player.STATE_ENDED) + assertEquals(1.75f, player.playbackParameters.speed) + + player.pause() + player.clearMediaItems() + listener.onPlaybackStateChanged(Player.STATE_IDLE) + player.setMediaItem(video("replacement", 2f)) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.75f, player.playbackParameters.speed) + assertEquals(1.25f, preferences.playerPreferences.value.defaultPlaybackSpeed) + } + + @Test + fun newSessionUsesPreferenceInsteadOfPreviousSessionSpeed() { + withSession { _, player -> + player.setMediaItem(video("first", 0.5f)) + player.setPlaybackSpeed(1.75f) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.75f, player.playbackParameters.speed) + } + runBlocking { preferences.updatePlayerPreferences { it.copy(defaultPlaybackSpeed = 1.5f) } } + withSession { _, player -> + player.setMediaItem(video("first", 0.5f)) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1.5f, player.playbackParameters.speed) + } + } + + private fun withSession(check: (PlayerService, Player) -> Unit) { + val controller = Robolectric.buildService(PlayerService::class.java).create() + val service = controller.get() + val session = PlayerService::class.java.getDeclaredField("mediaSession") + .apply { isAccessible = true }.get(service) as MediaSession + try { + check(service, session.player) + } finally { + controller.destroy() + shadowOf(Looper.getMainLooper()).idle() + } + } + + private fun video(id: String, legacySpeed: Float): MediaItem = MediaItem.Builder() + .setMediaId("file:///$id.mp4") + .setUri("file:///$id.mp4") + .setMediaMetadata( + MediaMetadata.Builder() + .setArtworkData(byteArrayOf(1), MediaMetadata.PICTURE_TYPE_FRONT_COVER) + .setExtras(Bundle().apply { putFloat("media_metadata_playback_speed", legacySpeed) }) + .build(), + ) + .build() +}