mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
add media3, the playback session and the sql-backed queue
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".SubtracksApp"
|
||||
@@ -25,6 +29,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
package com.subtracks
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.subtracks.ui.SubtracksRoot
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val requestNotifications =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermission()
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
SubtracksTheme {
|
||||
@@ -17,4 +26,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
val granted =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
||||
Room
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.addMigrations(*MIGRATIONS)
|
||||
.build()
|
||||
|
||||
@@ -14,6 +14,13 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal const val PLAYLIST_SONGS_SQL =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"ORDER BY playlist_songs.position"
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||
interface LibraryDao {
|
||||
@@ -97,13 +104,7 @@ interface LibraryDao {
|
||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
|
||||
fun playlists(sourceId: Long): PagingSource<Int, Playlist>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"ORDER BY playlist_songs.position",
|
||||
)
|
||||
@Query(PLAYLIST_SONGS_SQL)
|
||||
fun playlistSongs(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
val MIGRATION_1_2 =
|
||||
object : Migration(1, 2) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("DROP TABLE IF EXISTS `app_settings`")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArt` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 =
|
||||
object : Migration(2, 3) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `queue_entries` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
|
||||
"`refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, " +
|
||||
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `queue_entries` (`sourceId`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `queue_entries` (`position`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playback_cursor` (`id` INTEGER NOT NULL, " +
|
||||
"`queuePosition` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.SongListItem
|
||||
|
||||
private const val ALBUM_SONGS_SQL =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||
"ORDER BY songs.disc, songs.track"
|
||||
|
||||
@Dao
|
||||
interface QueueDao {
|
||||
@Query("DELETE FROM queue_entries")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert
|
||||
suspend fun insert(entries: List<QueueEntry>)
|
||||
|
||||
@Query("SELECT * FROM queue_entries ORDER BY position, id")
|
||||
suspend fun entries(): List<QueueEntry>
|
||||
|
||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||
suspend fun cursor(): PlaybackCursor?
|
||||
|
||||
@Upsert
|
||||
suspend fun setCursor(cursor: PlaybackCursor)
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId",
|
||||
)
|
||||
suspend fun playlistLength(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
): Long
|
||||
|
||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND albumId = :albumId")
|
||||
suspend fun albumLength(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): Long
|
||||
|
||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId")
|
||||
suspend fun songLength(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): Long
|
||||
|
||||
@Query("$PLAYLIST_SONGS_SQL LIMIT 1 OFFSET :offset")
|
||||
suspend fun playlistSongAt(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
offset: Long,
|
||||
): SongListItem?
|
||||
|
||||
@Query("$ALBUM_SONGS_SQL LIMIT 1 OFFSET :offset")
|
||||
suspend fun albumSongAt(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
offset: Long,
|
||||
): SongListItem?
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id = :songId LIMIT 1",
|
||||
)
|
||||
suspend fun song(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): SongListItem?
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKindConverter
|
||||
import com.subtracks.data.model.SearchIndex
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
@@ -21,14 +25,19 @@ import com.subtracks.data.model.SubsonicSource
|
||||
PlaylistSong::class,
|
||||
Song::class,
|
||||
SearchIndex::class,
|
||||
QueueEntry::class,
|
||||
PlaybackCursor::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
abstract fun sourcesDao(): SourcesDao
|
||||
|
||||
abstract fun searchDao(): SearchDao
|
||||
|
||||
abstract fun queueDao(): QueueDao
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.data.model
|
||||
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.ColumnTypeConverter
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
@@ -173,3 +174,45 @@ data class SearchIndex(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
enum class QueueKind { Playlist, Album, Song }
|
||||
|
||||
@Entity(
|
||||
tableName = "queue_entries",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Source::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId"), Index("position")],
|
||||
)
|
||||
data class QueueEntry(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val position: Long,
|
||||
val sourceId: Long,
|
||||
val kind: QueueKind,
|
||||
val refId: String,
|
||||
val rangeStart: Long? = null,
|
||||
val rangeEnd: Long? = null,
|
||||
) {
|
||||
val offset: Long get() = rangeStart ?: 0
|
||||
|
||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||
}
|
||||
|
||||
@Entity(tableName = "playback_cursor")
|
||||
data class PlaybackCursor(
|
||||
@PrimaryKey val id: Long = 1,
|
||||
val queuePosition: Long,
|
||||
)
|
||||
|
||||
class QueueKindConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromQueueKind(kind: QueueKind): String = kind.name
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toQueueKind(value: String): QueueKind = QueueKind.valueOf(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useWriterConnection
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongListItem
|
||||
|
||||
data class ResolvedQueueEntry(
|
||||
val entry: QueueEntry,
|
||||
val length: Long,
|
||||
)
|
||||
|
||||
data class QueueSnapshot(
|
||||
val entries: List<ResolvedQueueEntry>,
|
||||
) {
|
||||
val size: Long = entries.sumOf { it.length }
|
||||
|
||||
fun locate(position: Long): Pair<QueueEntry, Long>? {
|
||||
var remaining = position
|
||||
for (resolved in entries) {
|
||||
if (remaining < resolved.length) return resolved.entry to remaining
|
||||
remaining -= resolved.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
data class QueueWindowItem(
|
||||
val position: Long,
|
||||
val item: SongListItem,
|
||||
)
|
||||
|
||||
class QueueRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
) {
|
||||
private val dao get() = db.queueDao()
|
||||
|
||||
fun playlistEntry(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Playlist, refId = playlistId)
|
||||
|
||||
fun albumEntry(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Album, refId = albumId)
|
||||
|
||||
fun songEntry(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
|
||||
|
||||
suspend fun replace(entries: List<QueueEntry>) {
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.immediateTransaction {
|
||||
dao.clear()
|
||||
dao.insert(entries)
|
||||
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
||||
|
||||
suspend fun itemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): SongListItem? {
|
||||
val (entry, offset) = snapshot.locate(position) ?: return null
|
||||
return itemWithin(entry, offset)
|
||||
}
|
||||
|
||||
suspend fun window(
|
||||
snapshot: QueueSnapshot,
|
||||
center: Long,
|
||||
radius: Long,
|
||||
): List<QueueWindowItem> {
|
||||
if (snapshot.size == 0L) return emptyList()
|
||||
val first = (center - radius).coerceAtLeast(0)
|
||||
val last = (center + radius).coerceAtMost(snapshot.size - 1)
|
||||
return (first..last).mapNotNull { position ->
|
||||
itemAt(snapshot, position)?.let { QueueWindowItem(position, it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||
|
||||
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
||||
|
||||
private suspend fun itemWithin(
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
): SongListItem? {
|
||||
val index = entry.offset + offset
|
||||
return when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index)
|
||||
QueueKind.Album -> dao.albumSongAt(entry.sourceId, entry.refId, index)
|
||||
QueueKind.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||
val total =
|
||||
when (kind) {
|
||||
QueueKind.Song -> dao.songLength(sourceId, refId)
|
||||
QueueKind.Playlist -> dao.playlistLength(sourceId, refId)
|
||||
QueueKind.Album -> dao.albumLength(sourceId, refId)
|
||||
}
|
||||
val available = (total - offset).coerceAtLeast(0)
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.subtracks.data.sync.SyncService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -34,10 +35,16 @@ class SourceRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun sources(): Flow<List<Source>> = db.sourcesDao().sources()
|
||||
|
||||
fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId()
|
||||
|
||||
suspend fun activeSourceIdOnce(): Long? = db.sourcesDao().activeSourceIdOnce()
|
||||
|
||||
fun activeConfig(): Flow<SubsonicConfig?> = db.sourcesDao().activeSubsonicConfig()
|
||||
|
||||
fun coverArt(
|
||||
@@ -49,6 +56,8 @@ class SourceRepository(
|
||||
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
|
||||
}
|
||||
|
||||
fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString()
|
||||
|
||||
suspend fun addSource(
|
||||
name: String,
|
||||
address: String,
|
||||
|
||||
@@ -71,7 +71,8 @@ class SubsonicSource(
|
||||
"stream",
|
||||
buildMap {
|
||||
put("id", songId)
|
||||
put("estimateContentLength", "true")
|
||||
val transcodes = maxBitrate > 0 || !streamFormat.isNullOrEmpty()
|
||||
if (transcodes) put("estimateContentLength", "true")
|
||||
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
|
||||
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
|
||||
},
|
||||
|
||||
@@ -4,8 +4,12 @@ import android.content.Context
|
||||
import com.subtracks.data.db.createAndroidDatabase
|
||||
import com.subtracks.data.prefs.createUserPreferences
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlayerConnection
|
||||
import com.subtracks.ui.RootViewModel
|
||||
import com.subtracks.ui.library.AlbumDetailViewModel
|
||||
import com.subtracks.ui.library.LibraryViewModel
|
||||
@@ -26,10 +30,13 @@ fun appModule(
|
||||
single { SourceRepository(get(), get()) }
|
||||
single { LibraryRepository(get(), get()) }
|
||||
single { SyncManager(get()) }
|
||||
single { QueueRepository(get()) }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||
single { PlaybackController(get(), get(), get()) }
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get()) }
|
||||
viewModel { AddSourceViewModel(get(), get()) }
|
||||
viewModel { params -> AlbumDetailViewModel(get(), get(), params.get()) }
|
||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), params.get()) }
|
||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
|
||||
class Media3PlayerHandle(
|
||||
private val controller: MediaController,
|
||||
private val sourceRepository: SourceRepository,
|
||||
) : PlayerHandle {
|
||||
override val itemCount: Int get() = controller.mediaItemCount
|
||||
|
||||
override val currentIndex: Int get() = controller.currentMediaItemIndex
|
||||
|
||||
override val isIdle: Boolean get() = controller.playbackState == Player.STATE_IDLE
|
||||
|
||||
override val isEnded: Boolean get() = controller.playbackState == Player.STATE_ENDED
|
||||
|
||||
override val isBuffering: Boolean get() = controller.playbackState == Player.STATE_BUFFERING
|
||||
|
||||
override val isPlaying: Boolean get() = controller.isPlaying
|
||||
|
||||
override val playWhenReady: Boolean get() = controller.playWhenReady
|
||||
|
||||
override val durationMs: Long
|
||||
get() = controller.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
||||
|
||||
override val currentPositionMs: Long get() = controller.currentPosition.coerceAtLeast(0)
|
||||
|
||||
override val currentItem: QueueItem? get() = controller.currentMediaItem?.let(::toQueueItem)
|
||||
|
||||
override fun setWindow(
|
||||
items: List<QueueItem>,
|
||||
startIndex: Int,
|
||||
) = controller.setMediaItems(items.map(::toMediaItem), startIndex, 0)
|
||||
|
||||
override fun addFirst(item: QueueItem) = controller.addMediaItems(0, listOf(toMediaItem(item)))
|
||||
|
||||
override fun addLast(item: QueueItem) = controller.addMediaItems(controller.mediaItemCount, listOf(toMediaItem(item)))
|
||||
|
||||
override fun removeFirst() = controller.removeMediaItem(0)
|
||||
|
||||
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
|
||||
|
||||
override fun prepare() = controller.prepare()
|
||||
|
||||
override fun play() = controller.play()
|
||||
|
||||
override fun pause() = controller.pause()
|
||||
|
||||
override fun stop() = controller.stop()
|
||||
|
||||
override fun clear() = controller.clearMediaItems()
|
||||
|
||||
override fun seekToIndex(index: Int) {
|
||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
||||
controller.seekTo(index, 0)
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||
)
|
||||
controller.seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun addListener(listener: PlayerHandle.Listener) {
|
||||
controller.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
||||
listener.onTransition()
|
||||
}
|
||||
|
||||
override fun onEvents(
|
||||
player: Player,
|
||||
events: Player.Events,
|
||||
) = listener.onEvents()
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
||||
error,
|
||||
)
|
||||
listener.onError(error.errorCodeName)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"state=$playbackState position=${controller.currentPosition} " +
|
||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||
val metadata =
|
||||
MediaMetadata
|
||||
.Builder()
|
||||
.setTitle(item.title)
|
||||
.setArtist(item.artist)
|
||||
.setAlbumTitle(item.album)
|
||||
.setArtworkUri(sourceRepository.coverArt(item.coverArtId, true)?.url?.toUri())
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
},
|
||||
).build()
|
||||
return MediaItem
|
||||
.Builder()
|
||||
.setMediaId(item.id)
|
||||
.setUri(sourceRepository.streamUri(item.id))
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
||||
QueueItem(
|
||||
id = mediaItem.mediaId,
|
||||
title =
|
||||
mediaItem.mediaMetadata.title
|
||||
?.toString()
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||
?.getLong(EXTRA_DURATION_MS),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SubtracksPlayback"
|
||||
const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
const val EXTRA_DURATION_MS = "durationMs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
data class QueueItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val album: String?,
|
||||
val coverArtId: String?,
|
||||
val durationMs: Long? = null,
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
val kind: QueueKind,
|
||||
val refId: String,
|
||||
)
|
||||
|
||||
data class PlaybackState(
|
||||
val item: QueueItem? = null,
|
||||
val context: QueueContext? = null,
|
||||
val error: String? = null,
|
||||
val isBuffering: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val hasNext: Boolean = false,
|
||||
val hasPrevious: Boolean = false,
|
||||
)
|
||||
|
||||
class PlaybackController(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
private val connection: PlayerConnection,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _state = MutableStateFlow(PlaybackState())
|
||||
val state: StateFlow<PlaybackState> = _state
|
||||
|
||||
private var player: PlayerHandle? = null
|
||||
private var connecting = false
|
||||
private var pendingPlay: Pair<List<QueueEntry>, Long>? = null
|
||||
private var positionJob: Job? = null
|
||||
private var windowJob: Job? = null
|
||||
private var bufferingJob: Job? = null
|
||||
private var showBuffering = false
|
||||
private val startLock = Mutex()
|
||||
|
||||
private var snapshot: QueueSnapshot? = null
|
||||
private var lastDurationMs = 0L
|
||||
private var lastError: String? = null
|
||||
private var queueSourceId: Long? = null
|
||||
private var windowStart = 0L
|
||||
private var windowEnd = -1L
|
||||
private var updating = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
sourceRepository.activeSourceId().collect { sourceId ->
|
||||
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (player != null || connecting) return
|
||||
connecting = true
|
||||
connection.connect(
|
||||
onConnected = { handle ->
|
||||
connecting = false
|
||||
player = handle
|
||||
handle.addListener(playerListener)
|
||||
scope.launch {
|
||||
val pending = pendingPlay
|
||||
pendingPlay = null
|
||||
if (pending != null) start(pending.first, pending.second) else restore()
|
||||
}
|
||||
},
|
||||
onDisconnected = {
|
||||
player = null
|
||||
connecting = false
|
||||
scope.launch { stop() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun playAlbum(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
startOrdinal: Long,
|
||||
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), startOrdinal)
|
||||
|
||||
fun playPlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
startPosition: Long,
|
||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
|
||||
|
||||
fun togglePlayPause() {
|
||||
val player = player ?: return
|
||||
if (player.isEnded) {
|
||||
player.seekToIndex(player.currentIndex)
|
||||
player.play()
|
||||
return
|
||||
}
|
||||
player.ensurePrepared()
|
||||
if (player.playWhenReady) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun next() = skip(1)
|
||||
|
||||
fun previous() = skip(-1)
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
player?.seekTo(positionMs)
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
item: QueueItem?,
|
||||
thumbnail: Boolean = false,
|
||||
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
|
||||
|
||||
private fun play(
|
||||
entries: List<QueueEntry>,
|
||||
startPosition: Long,
|
||||
) {
|
||||
if (player == null) {
|
||||
pendingPlay = entries to startPosition
|
||||
connect()
|
||||
return
|
||||
}
|
||||
scope.launch { start(entries, startPosition) }
|
||||
}
|
||||
|
||||
private suspend fun start(
|
||||
entries: List<QueueEntry>,
|
||||
startPosition: Long,
|
||||
) = startLock.withLock {
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
lastDurationMs = 0
|
||||
lastError = null
|
||||
queueRepository.replace(entries)
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
queueSourceId = entries.firstOrNull()?.sourceId
|
||||
if (snapshot.size == 0L) {
|
||||
stopLocked()
|
||||
return@withLock
|
||||
}
|
||||
val position = startPosition.coerceIn(0, snapshot.size - 1)
|
||||
queueRepository.setCursor(position)
|
||||
loadWindow(position, autoplay = true)
|
||||
}
|
||||
|
||||
private suspend fun restore() =
|
||||
startLock.withLock {
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
val queueSourceId =
|
||||
snapshot.entries
|
||||
.firstOrNull()
|
||||
?.entry
|
||||
?.sourceId
|
||||
val activeId = sourceRepository.activeSourceIdOnce()
|
||||
if (snapshot.size == 0L || (queueSourceId != null && activeId != null && queueSourceId != activeId)) {
|
||||
stopLocked()
|
||||
return@withLock
|
||||
}
|
||||
this.queueSourceId = queueSourceId
|
||||
val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
|
||||
loadWindow(position, autoplay = false)
|
||||
}
|
||||
|
||||
private suspend fun stop() = startLock.withLock { stopLocked() }
|
||||
|
||||
private fun stopLocked() {
|
||||
windowJob?.cancel()
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
showBuffering = false
|
||||
snapshot = null
|
||||
lastDurationMs = 0
|
||||
lastError = null
|
||||
queueSourceId = null
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
player?.run {
|
||||
stop()
|
||||
clear()
|
||||
}
|
||||
_state.value = PlaybackState()
|
||||
stopPositionTicker()
|
||||
}
|
||||
|
||||
private suspend fun loadWindow(
|
||||
position: Long,
|
||||
autoplay: Boolean,
|
||||
) {
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||
val startIndex = window.indexOfFirst { it.position == position }
|
||||
if (startIndex < 0) return
|
||||
windowStart = window.first().position
|
||||
windowEnd = window.last().position
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex)
|
||||
updating = false
|
||||
if (autoplay) {
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
refresh(position)
|
||||
}
|
||||
|
||||
private fun skip(delta: Long) {
|
||||
scope.launch {
|
||||
val current = currentPosition() ?: return@launch
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
if (snapshot.size == 0L) return@launch
|
||||
val position = current.coerceIn(0, snapshot.size - 1)
|
||||
val target = (position + delta).coerceIn(0, snapshot.size - 1)
|
||||
if (target == position) return@launch
|
||||
windowJob?.cancel()
|
||||
queueRepository.setCursor(target)
|
||||
shiftWindow(target)
|
||||
val player = player ?: return@launch
|
||||
player.ensurePrepared()
|
||||
player.seekToIndex((target - windowStart).toInt())
|
||||
refresh(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onPositionChanged() {
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
||||
queueRepository.setCursor(position)
|
||||
refresh(position)
|
||||
scheduleWindowShift(position)
|
||||
}
|
||||
|
||||
private fun scheduleWindowShift(center: Long) {
|
||||
windowJob?.cancel()
|
||||
windowJob =
|
||||
scope.launch {
|
||||
delay(WINDOW_SHIFT_DELAY_MS)
|
||||
shiftWindow(center)
|
||||
refresh(center)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shiftWindow(center: Long) =
|
||||
startLock.withLock {
|
||||
val player = player ?: return@withLock
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val target = center.coerceIn(0, snapshot.size - 1)
|
||||
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
|
||||
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
|
||||
updating = true
|
||||
try {
|
||||
while (windowStart > desiredStart) {
|
||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item.toQueueItem())
|
||||
windowStart--
|
||||
}
|
||||
while (windowStart < desiredStart) {
|
||||
player.removeFirst()
|
||||
windowStart++
|
||||
}
|
||||
while (windowEnd < desiredEnd) {
|
||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item.toQueueItem())
|
||||
windowEnd++
|
||||
}
|
||||
while (windowEnd > desiredEnd) {
|
||||
player.removeLast()
|
||||
windowEnd--
|
||||
}
|
||||
} finally {
|
||||
updating = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentPosition(): Long? {
|
||||
val player = player ?: return null
|
||||
if (player.itemCount == 0) return null
|
||||
return windowStart + player.currentIndex
|
||||
}
|
||||
|
||||
private val playerListener =
|
||||
object : PlayerHandle.Listener {
|
||||
override fun onTransition() {
|
||||
lastError = null
|
||||
if (updating) return
|
||||
scope.launch { onPositionChanged() }
|
||||
}
|
||||
|
||||
override fun onEvents() = refresh()
|
||||
|
||||
override fun onError(message: String) {
|
||||
lastError = message
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun contextAt(position: Long?): QueueContext? {
|
||||
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
|
||||
return QueueContext(entry.kind, entry.refId)
|
||||
}
|
||||
|
||||
private fun updateBuffering() {
|
||||
if (player?.isBuffering != true) {
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
showBuffering = false
|
||||
return
|
||||
}
|
||||
if (showBuffering || bufferingJob?.isActive == true) return
|
||||
bufferingJob =
|
||||
scope.launch {
|
||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||
if (player?.isBuffering == true) {
|
||||
showBuffering = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(position: Long? = currentPosition()) {
|
||||
val player = player ?: return
|
||||
updateBuffering()
|
||||
val size = snapshot?.size ?: 0
|
||||
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
|
||||
_state.value =
|
||||
PlaybackState(
|
||||
item = player.currentItem,
|
||||
context = contextAt(position),
|
||||
error = lastError,
|
||||
isBuffering = showBuffering,
|
||||
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
||||
positionMs = player.currentPositionMs,
|
||||
durationMs = lastDurationMs,
|
||||
hasNext = position != null && position < size - 1,
|
||||
hasPrevious = position != null && position > 0,
|
||||
)
|
||||
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
||||
}
|
||||
|
||||
private fun startPositionTicker() {
|
||||
if (positionJob?.isActive == true) return
|
||||
positionJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(POSITION_TICK_MS)
|
||||
val player = player ?: break
|
||||
_state.value = _state.value.copy(positionMs = player.currentPositionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPositionTicker() {
|
||||
positionJob?.cancel()
|
||||
positionJob = null
|
||||
}
|
||||
|
||||
private fun PlayerHandle.ensurePrepared() {
|
||||
if (isIdle) prepare()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POSITION_TICK_MS = 500L
|
||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||
const val QUEUE_WINDOW_RADIUS = 25L
|
||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.subtracks.MainActivity
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var session: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
true,
|
||||
).setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this).setDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||
).build()
|
||||
session =
|
||||
MediaSession
|
||||
.Builder(this, player)
|
||||
.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
pauseAllPlayersAndStopSelf()
|
||||
}
|
||||
|
||||
private fun streamingClient(): OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val response = chain.proceed(chain.request())
|
||||
Log.d(
|
||||
STREAM_TAG,
|
||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
||||
"contentRange=${response.header("Content-Range")} " +
|
||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||
)
|
||||
response
|
||||
}.build()
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
session?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
interface PlayerConnection {
|
||||
fun connect(
|
||||
onConnected: (PlayerHandle) -> Unit,
|
||||
onDisconnected: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
class MediaSessionConnection(
|
||||
private val context: Context,
|
||||
private val sourceRepository: SourceRepository,
|
||||
) : PlayerConnection {
|
||||
override fun connect(
|
||||
onConnected: (PlayerHandle) -> Unit,
|
||||
onDisconnected: () -> Unit,
|
||||
) {
|
||||
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val future =
|
||||
MediaController
|
||||
.Builder(context, token)
|
||||
.setListener(
|
||||
object : MediaController.Listener {
|
||||
override fun onDisconnected(controller: MediaController) = onDisconnected()
|
||||
},
|
||||
).buildAsync()
|
||||
future.addListener(
|
||||
{
|
||||
val controller = runCatching { future.get() }.getOrNull()
|
||||
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository))
|
||||
},
|
||||
Executor { it.run() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
interface PlayerHandle {
|
||||
val itemCount: Int
|
||||
val currentIndex: Int
|
||||
val isIdle: Boolean
|
||||
val isEnded: Boolean
|
||||
val isBuffering: Boolean
|
||||
val isPlaying: Boolean
|
||||
val playWhenReady: Boolean
|
||||
val durationMs: Long
|
||||
val currentPositionMs: Long
|
||||
val currentItem: QueueItem?
|
||||
|
||||
fun setWindow(
|
||||
items: List<QueueItem>,
|
||||
startIndex: Int,
|
||||
)
|
||||
|
||||
fun addFirst(item: QueueItem)
|
||||
|
||||
fun addLast(item: QueueItem)
|
||||
|
||||
fun removeFirst()
|
||||
|
||||
fun removeLast()
|
||||
|
||||
fun prepare()
|
||||
|
||||
fun play()
|
||||
|
||||
fun pause()
|
||||
|
||||
fun stop()
|
||||
|
||||
fun clear()
|
||||
|
||||
fun seekToIndex(index: Int)
|
||||
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
fun addListener(listener: Listener)
|
||||
|
||||
interface Listener {
|
||||
fun onTransition()
|
||||
|
||||
fun onEvents()
|
||||
|
||||
fun onError(message: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.SongListItem
|
||||
|
||||
fun SongListItem.toQueueItem() =
|
||||
QueueItem(
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
)
|
||||
Reference in New Issue
Block a user