diff --git a/app/build.gradle b/app/build.gradle
index 421e7d3b9..8c1ce4883 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -227,12 +227,13 @@ dependencies {
/** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib"
- implementation 'androidx.compose.animation:animation:1.3.1'
- implementation 'androidx.compose.foundation:foundation:1.3.1'
- implementation 'androidx.compose.material3:material3:1.0.1'
- implementation 'androidx.compose.material:material-icons-extended:1.3.1'
- implementation 'androidx.compose.ui:ui:1.3.1'
- implementation 'androidx.compose.ui:ui-tooling-preview:1.3.1'
+ implementation platform('androidx.compose:compose-bom:2025.08.01')
+ implementation 'androidx.compose.animation:animation'
+ implementation 'androidx.compose.foundation:foundation'
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.compose.material:material-icons-extended'
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.3.1'
@@ -273,6 +274,13 @@ dependencies {
// Media player
implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}"
+ implementation "androidx.media3:media3-session:1.8.0"
+ implementation "androidx.media3:media3-exoplayer:1.8.0"
+ implementation "androidx.media3:media3-ui:1.8.0"
+ implementation "androidx.media3:media3-common:1.8.0"
+ implementation "androidx.media3:media3-datasource:1.8.0"
+ implementation "androidx.media3:media3-exoplayer-dash:1.8.0"
+ implementation "androidx.media3:media3-exoplayer-hls:1.8.0"
// Metadata generator for service descriptors
compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5152dd485..5a6308498 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -75,6 +75,16 @@
android:foregroundServiceType="mediaPlayback">
+
+
+
+
+
+
+
+ val from = SharedContext.queueManager.getIndexOfItemUuid(item.uuid)
+ SharedContext.queueManager.moveItem(from, currentIndex + 1 + offset)
+ }
+ }
+ }
+ }
+
+ @JvmStatic
+ fun seekTo(positionMs: Long) {
+ SharedContext.platformMediaController?.seekTo(positionMs)
+ }
+
+ private suspend fun awaitController(): project.pipepipe.app.platform.PlatformMediaController? {
+ repeat(50) {
+ SharedContext.platformMediaController?.let { return it }
+ delay(100)
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/LegacyPlayQueueAdapter.kt b/app/src/main/java/project/pipepipe/app/LegacyPlayQueueAdapter.kt
new file mode 100644
index 000000000..ba20c2352
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/LegacyPlayQueueAdapter.kt
@@ -0,0 +1,17 @@
+package project.pipepipe.app
+
+import org.schabi.newpipe.player.playqueue.PlayQueue
+import project.pipepipe.app.platform.PlatformMediaItem
+
+object LegacyPlayQueueAdapter {
+ fun convert(queue: PlayQueue): List = queue.streams.map {
+ PlatformMediaItem(
+ mediaId = it.url,
+ title = it.title,
+ artist = it.uploader,
+ artworkUrl = it.thumbnailUrl,
+ durationMs = it.duration.takeIf { duration -> duration > 0 }?.times(1000),
+ serviceId = it.serviceId
+ )
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/PlaybackMode.kt b/app/src/main/java/project/pipepipe/app/PlaybackMode.kt
new file mode 100644
index 000000000..e029f315a
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/PlaybackMode.kt
@@ -0,0 +1,7 @@
+package project.pipepipe.app
+
+enum class PlaybackMode {
+ VIDEO_AUDIO,
+ AUDIO_ONLY,
+ POPUP
+}
diff --git a/app/src/main/java/project/pipepipe/app/QueueManager.kt b/app/src/main/java/project/pipepipe/app/QueueManager.kt
new file mode 100644
index 000000000..0e8f54b0c
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/QueueManager.kt
@@ -0,0 +1,101 @@
+package project.pipepipe.app
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import project.pipepipe.app.platform.PlatformMediaController
+import project.pipepipe.app.platform.PlatformMediaItem
+
+class QueueManager {
+ private val mutableQueue = MutableStateFlow>(emptyList())
+ val queue: StateFlow> = mutableQueue.asStateFlow()
+
+ private var controller: PlatformMediaController? = null
+ private var backup: MutableList? = null
+
+ fun attachController(controller: PlatformMediaController?) {
+ this.controller = controller
+ }
+
+ fun getCurrentQueue(): List = mutableQueue.value
+
+ fun getIndexOfItemUuid(uuid: String): Int =
+ mutableQueue.value.indexOfFirst { it.uuid == uuid }
+
+ fun isShuffled(): Boolean = backup != null
+
+ fun setQueue(items: List, startIndex: Int = 0, notifyOnly: Boolean = false) {
+ mutableQueue.value = items.toList()
+ backup = null
+ if (!notifyOnly) {
+ controller?.setQueue(items, startIndex)
+ }
+ }
+
+ fun addItem(item: PlatformMediaItem) {
+ backup?.add(item)
+ mutableQueue.value += item
+ controller?.syncQueueAppend(item)
+ }
+
+ fun removeItemByUuid(uuid: String) {
+ val index = getIndexOfItemUuid(uuid)
+ if (index < 0) {
+ return
+ }
+ mutableQueue.value = mutableQueue.value.toMutableList().apply { removeAt(index) }
+ backup?.removeAll { it.uuid == uuid }
+ controller?.syncQueueRemove(index)
+ }
+
+ fun updateItemExtras(uuid: String, newExtras: Map) {
+ val index = getIndexOfItemUuid(uuid)
+ if (index < 0) {
+ return
+ }
+ val item = mutableQueue.value[index]
+ val updatedItem = item.copy(extras = ((item.extras ?: emptyMap()) + newExtras).ifEmpty { null })
+ mutableQueue.value = mutableQueue.value.toMutableList().apply { set(index, updatedItem) }
+ backup?.indexOfFirst { it.uuid == uuid }?.takeIf { it >= 0 }?.let { backup?.set(it, updatedItem) }
+ }
+
+ fun moveItem(from: Int, to: Int) {
+ if (from !in mutableQueue.value.indices || to !in mutableQueue.value.indices || from == to) {
+ return
+ }
+ mutableQueue.value = mutableQueue.value.toMutableList().apply {
+ add(to, removeAt(from))
+ }
+ backup?.let {
+ val item = it.removeAt(from)
+ it.add(to, item)
+ }
+ controller?.syncQueueMove(from, to)
+ }
+
+ fun clear() {
+ mutableQueue.value = emptyList()
+ backup = null
+ controller?.syncQueueClear()
+ }
+
+ fun shuffle(currentUuid: String?) {
+ if (mutableQueue.value.size <= 1 || currentUuid == null) {
+ return
+ }
+ if (backup == null) {
+ backup = mutableQueue.value.toMutableList()
+ }
+ val currentItem = mutableQueue.value.firstOrNull { it.uuid == currentUuid } ?: return
+ mutableQueue.value = mutableQueue.value.shuffled().filterNot { it.uuid == currentUuid }
+ .toMutableList().apply { add(0, currentItem) }
+ controller?.syncQueueShuffle()
+ }
+
+ fun unshuffle() {
+ val restoredQueue = backup ?: return
+ mutableQueue.value = restoredQueue.toList()
+ backup = null
+ controller?.syncQueueShuffle()
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/SharedContext.kt b/app/src/main/java/project/pipepipe/app/SharedContext.kt
new file mode 100644
index 000000000..744470949
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/SharedContext.kt
@@ -0,0 +1,28 @@
+package project.pipepipe.app
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import project.pipepipe.app.platform.PlatformMediaController
+
+object SharedContext {
+ val queueManager = QueueManager()
+
+ private val mutablePlaybackMode = MutableStateFlow(PlaybackMode.VIDEO_AUDIO)
+ val playbackMode: StateFlow = mutablePlaybackMode.asStateFlow()
+
+ private val mutableMediaController = MutableStateFlow(null)
+ val mediaController: StateFlow = mutableMediaController.asStateFlow()
+
+ var platformMediaController: PlatformMediaController? = null
+ set(value) {
+ field = value
+ mutableMediaController.value = value
+ queueManager.attachController(value)
+ }
+
+ fun updatePlaybackMode(mode: PlaybackMode) {
+ platformMediaController?.applyPlaybackMode(mode)
+ mutablePlaybackMode.value = mode
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt
new file mode 100644
index 000000000..442e9c45c
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt
@@ -0,0 +1,220 @@
+package project.pipepipe.app.mediasource
+
+import android.content.Context
+import android.net.Uri
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.exoplayer.dash.DashMediaSource
+import androidx.media3.exoplayer.dash.manifest.DashManifestParser
+import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
+import androidx.media3.exoplayer.hls.HlsMediaSource
+import androidx.media3.exoplayer.source.MediaSource
+import androidx.media3.exoplayer.source.MergingMediaSource
+import androidx.media3.exoplayer.source.ProgressiveMediaSource
+import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
+import org.schabi.newpipe.DownloaderImpl
+import org.schabi.newpipe.extractor.ServiceList
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator
+import org.schabi.newpipe.extractor.stream.AudioStream
+import org.schabi.newpipe.extractor.stream.DeliveryMethod
+import org.schabi.newpipe.extractor.stream.Stream
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.extractor.stream.StreamType
+import org.schabi.newpipe.extractor.stream.VideoStream
+import org.schabi.newpipe.util.ExtractorHelper
+import java.io.ByteArrayInputStream
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+@UnstableApi
+class ExtractorMediaSourceFactory(
+ context: Context
+) : MediaSource.Factory {
+ private val context = context.applicationContext
+ private val dataSourceFactory = createDataSourceFactory(
+ mapOf("Referer" to "https://www.bilibili.com")
+ )
+
+ private fun createDataSourceFactory(headers: Map): DefaultDataSource.Factory =
+ DefaultDataSource.Factory(
+ context.applicationContext,
+ DefaultHttpDataSource.Factory()
+ .setUserAgent(DownloaderImpl.USER_AGENT)
+ .setDefaultRequestProperties(headers)
+ .setConnectTimeoutMs(30_000)
+ .setReadTimeoutMs(30_000)
+ )
+
+ override fun setDrmSessionManagerProvider(
+ drmSessionManagerProvider: DrmSessionManagerProvider
+ ): MediaSource.Factory = this
+
+ override fun setLoadErrorHandlingPolicy(
+ loadErrorHandlingPolicy: LoadErrorHandlingPolicy
+ ): MediaSource.Factory = this
+
+ override fun getSupportedTypes(): IntArray = intArrayOf(
+ C.CONTENT_TYPE_DASH,
+ C.CONTENT_TYPE_HLS,
+ C.CONTENT_TYPE_OTHER
+ )
+
+ override fun createMediaSource(mediaItem: MediaItem): MediaSource {
+ val streamInfo = StreamInfoRepository.get(mediaItem.mediaId)
+ ?: ExtractorHelper.getNewStreamInfo(
+ mediaItem.mediaMetadata.extras?.getInt(MediaItemFactory.KEY_SERVICE_ID) ?: -1,
+ mediaItem.mediaId
+ ).also(StreamInfoRepository::put)
+ return createMediaSource(mediaItem, streamInfo)
+ }
+
+ fun createMediaSource(mediaItem: MediaItem, streamInfo: StreamInfo): MediaSource {
+ if (streamInfo.dashMpdUrl.isNotEmpty()) {
+ return DashMediaSource.Factory(dataSourceFactory).createMediaSource(
+ mediaItem.withUri(streamInfo.dashMpdUrl, MimeTypes.APPLICATION_MPD)
+ )
+ }
+ if (streamInfo.hlsUrl.isNotEmpty()) {
+ return HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
+ mediaItem.withUri(streamInfo.hlsUrl, MimeTypes.APPLICATION_M3U8)
+ )
+ }
+
+ val video = selectVideo(streamInfo)
+ val audio = selectAudio(streamInfo)
+ val sources = buildList {
+ video?.let { add(createStreamSource(mediaItem, it, streamInfo)) }
+ if (video == null || video.isVideoOnly()) {
+ audio?.let { add(createStreamSource(mediaItem, it, streamInfo)) }
+ }
+ }
+ require(sources.isNotEmpty())
+ return if (sources.size == 1) sources.single() else MergingMediaSource(*sources.toTypedArray())
+ }
+
+ private fun selectVideo(streamInfo: StreamInfo): VideoStream? =
+ (streamInfo.videoStreams + streamInfo.videoOnlyStreams)
+ .filter { it.deliveryMethod != DeliveryMethod.TORRENT }
+ .maxWithOrNull(compareBy { it.height }.thenBy { it.fps })
+
+ private fun selectAudio(streamInfo: StreamInfo): AudioStream? =
+ streamInfo.audioStreams
+ .filter { it.deliveryMethod != DeliveryMethod.TORRENT }
+ .maxByOrNull { it.averageBitrate }
+
+ private fun createStreamSource(
+ mediaItem: MediaItem,
+ stream: Stream,
+ streamInfo: StreamInfo
+ ): MediaSource {
+ if (streamInfo.service == ServiceList.YouTube) {
+ return createYoutubeStreamSource(mediaItem, stream, streamInfo)
+ }
+ if (streamInfo.service == ServiceList.NicoNico && stream.content.contains("#cookie=")) {
+ val sourceUrl = stream.content.substringBefore("#cookie=")
+ val cookie = URLDecoder.decode(
+ stream.content.substringAfter("#cookie=").substringBefore("&length="),
+ StandardCharsets.UTF_8.name()
+ )
+ return HlsMediaSource.Factory(createDataSourceFactory(mapOf("Cookie" to cookie)))
+ .createMediaSource(mediaItem.withUri(sourceUrl, MimeTypes.APPLICATION_M3U8))
+ }
+ if (streamInfo.service == ServiceList.BiliBili) {
+ return ProgressiveMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, null))
+ }
+ return when (stream.deliveryMethod) {
+ DeliveryMethod.PROGRESSIVE_HTTP -> ProgressiveMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, null))
+ DeliveryMethod.DASH -> createDashSource(mediaItem, stream)
+ DeliveryMethod.HLS -> HlsMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, MimeTypes.APPLICATION_M3U8))
+ else -> error("Unsupported delivery method: ${stream.deliveryMethod}")
+ }
+ }
+
+ private fun createYoutubeStreamSource(
+ mediaItem: MediaItem,
+ stream: Stream,
+ streamInfo: StreamInfo
+ ): MediaSource {
+ if (streamInfo.streamType == StreamType.POST_LIVE_STREAM) {
+ val itag = requireNotNull(stream.itagItem)
+ val manifest = YoutubePostLiveStreamDvrDashManifestCreator
+ .fromPostLiveStreamDvrStreamingUrl(
+ stream.content,
+ itag,
+ itag.targetDurationSec,
+ streamInfo.duration
+ )
+ return createDashManifestSource(mediaItem, manifest, stream.content)
+ }
+ return when (stream.deliveryMethod) {
+ DeliveryMethod.PROGRESSIVE_HTTP -> {
+ if ((stream is VideoStream && stream.isVideoOnly()) || stream is AudioStream) {
+ runCatching {
+ YoutubeProgressiveDashManifestCreator.fromProgressiveStreamingUrl(
+ stream.content,
+ requireNotNull(stream.itagItem),
+ streamInfo.duration
+ )
+ }.fold(
+ onSuccess = { createDashManifestSource(mediaItem, it, stream.content) },
+ onFailure = {
+ ProgressiveMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, null))
+ }
+ )
+ } else {
+ ProgressiveMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, null))
+ }
+ }
+ DeliveryMethod.DASH -> {
+ val manifest = YoutubeOtfDashManifestCreator.fromOtfStreamingUrl(
+ stream.content,
+ requireNotNull(stream.itagItem),
+ streamInfo.duration
+ )
+ createDashManifestSource(mediaItem, manifest, stream.content)
+ }
+ DeliveryMethod.HLS -> HlsMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem.withUri(stream.content, MimeTypes.APPLICATION_M3U8))
+ else -> error("Unsupported YouTube delivery method: ${stream.deliveryMethod}")
+ }
+ }
+
+ private fun createDashSource(mediaItem: MediaItem, stream: Stream): MediaSource {
+ val factory = DashMediaSource.Factory(dataSourceFactory)
+ if (stream.isUrl) {
+ return factory.createMediaSource(mediaItem.withUri(stream.content, MimeTypes.APPLICATION_MPD))
+ }
+ return createDashManifestSource(mediaItem, stream.content, stream.manifestUrl ?: "")
+ }
+
+ private fun createDashManifestSource(
+ mediaItem: MediaItem,
+ manifestContent: String,
+ manifestUrl: String
+ ): MediaSource {
+ val manifestUri = Uri.parse(manifestUrl)
+ val manifest = DashManifestParser().parse(
+ manifestUri,
+ ByteArrayInputStream(manifestContent.toByteArray(StandardCharsets.UTF_8))
+ )
+ return DashMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(manifest, mediaItem.withUri(manifestUri, MimeTypes.APPLICATION_MPD))
+ }
+
+ private fun MediaItem.withUri(uri: String, mimeType: String?): MediaItem =
+ withUri(Uri.parse(uri), mimeType)
+
+ private fun MediaItem.withUri(uri: Uri, mimeType: String?): MediaItem =
+ buildUpon().setUri(uri).setMimeType(mimeType).build()
+}
diff --git a/app/src/main/java/project/pipepipe/app/mediasource/MediaItemFactory.kt b/app/src/main/java/project/pipepipe/app/mediasource/MediaItemFactory.kt
new file mode 100644
index 000000000..83260abad
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/mediasource/MediaItemFactory.kt
@@ -0,0 +1,57 @@
+package project.pipepipe.app.mediasource
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import project.pipepipe.app.platform.PlatformMediaItem
+
+object MediaItemFactory {
+ fun fromStreamInfo(streamInfo: StreamInfo, uuid: String? = null): PlatformMediaItem {
+ StreamInfoRepository.put(streamInfo)
+ return PlatformMediaItem(
+ mediaId = streamInfo.url,
+ title = streamInfo.name,
+ artist = streamInfo.uploaderName,
+ artworkUrl = streamInfo.thumbnailUrl,
+ durationMs = streamInfo.duration.takeIf { it > 0 }?.times(1000),
+ serviceId = streamInfo.serviceId,
+ uuid = uuid ?: java.util.UUID.randomUUID().toString()
+ )
+ }
+
+ fun toMediaItem(item: PlatformMediaItem): MediaItem {
+ val extras = Bundle().apply {
+ item.serviceId?.let { putInt(KEY_SERVICE_ID, it) }
+ putString(KEY_UUID, item.uuid)
+ }
+ return MediaItem.Builder()
+ .setMediaId(item.mediaId)
+ .setUri(Uri.EMPTY)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(item.title)
+ .setArtist(item.artist)
+ .setArtworkUri(item.artworkUrl?.let(Uri::parse))
+ .setDurationMs(item.durationMs)
+ .setExtras(extras)
+ .build()
+ )
+ .build()
+ }
+
+ fun fromMediaItem(item: MediaItem): PlatformMediaItem = PlatformMediaItem(
+ mediaId = item.mediaId,
+ title = item.mediaMetadata.title?.toString(),
+ artist = item.mediaMetadata.artist?.toString(),
+ artworkUrl = item.mediaMetadata.artworkUri?.toString(),
+ durationMs = item.mediaMetadata.durationMs,
+ serviceId = item.mediaMetadata.extras?.getInt(KEY_SERVICE_ID),
+ uuid = item.mediaMetadata.extras?.getString(KEY_UUID)
+ ?: java.util.UUID.randomUUID().toString()
+ )
+
+ const val KEY_SERVICE_ID = "KEY_SERVICE_ID"
+ const val KEY_UUID = "KEY_UUID"
+}
diff --git a/app/src/main/java/project/pipepipe/app/mediasource/StreamInfoRepository.kt b/app/src/main/java/project/pipepipe/app/mediasource/StreamInfoRepository.kt
new file mode 100644
index 000000000..acb8b9e27
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/mediasource/StreamInfoRepository.kt
@@ -0,0 +1,22 @@
+package project.pipepipe.app.mediasource
+
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import java.util.concurrent.ConcurrentHashMap
+
+object StreamInfoRepository {
+ private val streamInfos = ConcurrentHashMap()
+
+ fun put(streamInfo: StreamInfo) {
+ streamInfos[streamInfo.url] = streamInfo
+ }
+
+ fun get(url: String): StreamInfo? = streamInfos[url]
+
+ fun remove(url: String) {
+ streamInfos.remove(url)
+ }
+
+ fun clear() {
+ streamInfos.clear()
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt b/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt
new file mode 100644
index 000000000..ee52a7787
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt
@@ -0,0 +1,225 @@
+package project.pipepipe.app.platform
+
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.TrackSelectionOverride
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import project.pipepipe.app.PlaybackMode
+import project.pipepipe.app.SharedContext
+import project.pipepipe.app.mediasource.MediaItemFactory
+
+@UnstableApi
+class AndroidMediaController(
+ private val player: Player,
+ private val onStopService: () -> Unit = {}
+) : PlatformMediaController {
+ override val scope = CoroutineScope(Dispatchers.Main + Job())
+
+ private val mutableIsPlaying = MutableStateFlow(player.isPlaying)
+ override val isPlaying: StateFlow = mutableIsPlaying.asStateFlow()
+ private val mutableCurrentPosition = MutableStateFlow(player.currentPosition)
+ override val currentPosition: StateFlow = mutableCurrentPosition.asStateFlow()
+ private val mutableDuration = MutableStateFlow(player.duration)
+ override val duration: StateFlow = mutableDuration.asStateFlow()
+ private val mutablePlaybackState = MutableStateFlow(player.playbackState.toPlaybackState())
+ override val playbackState: StateFlow = mutablePlaybackState.asStateFlow()
+ private val mutableCurrentSubtitles = MutableStateFlow>(emptyList())
+ override val currentSubtitles: StateFlow> = mutableCurrentSubtitles.asStateFlow()
+ private val mutableCurrentMediaItem =
+ MutableStateFlow(player.currentMediaItem?.let(MediaItemFactory::fromMediaItem))
+ override val currentMediaItem: StateFlow = mutableCurrentMediaItem.asStateFlow()
+ private val mutableCurrentItemIndex = MutableStateFlow(player.currentMediaItemIndex)
+ override val currentItemIndex: StateFlow = mutableCurrentItemIndex.asStateFlow()
+ private val mutableRepeatMode = MutableStateFlow(player.repeatMode.toRepeatMode())
+ override val repeatMode: StateFlow = mutableRepeatMode.asStateFlow()
+ private val mutableShuffleModeEnabled = MutableStateFlow(player.shuffleModeEnabled)
+ override val shuffleModeEnabled: StateFlow = mutableShuffleModeEnabled.asStateFlow()
+ private val mutablePlaybackSpeed = MutableStateFlow(player.playbackParameters.speed)
+ override val playbackSpeed: StateFlow = mutablePlaybackSpeed.asStateFlow()
+ private val mutablePlaybackPitch = MutableStateFlow(player.playbackParameters.pitch)
+ override val playbackPitch: StateFlow = mutablePlaybackPitch.asStateFlow()
+ private val mutableBufferedPosition = MutableStateFlow(player.bufferedPosition)
+ override val bufferedPosition: StateFlow = mutableBufferedPosition.asStateFlow()
+ private val mutableAvailableResolutions = MutableStateFlow>(emptyList())
+ override val availableResolutions: StateFlow> =
+ mutableAvailableResolutions.asStateFlow()
+ private val mutableAvailableSubtitles = MutableStateFlow>(emptyList())
+ override val availableSubtitles: StateFlow> = mutableAvailableSubtitles.asStateFlow()
+ private val mutableAvailableAudioLanguages = MutableStateFlow>(emptyList())
+ override val availableAudioLanguages: StateFlow> =
+ mutableAvailableAudioLanguages.asStateFlow()
+ private val mutableCurrentAudioLanguage = MutableStateFlow("")
+ override val currentAudioLanguage: StateFlow = mutableCurrentAudioLanguage.asStateFlow()
+
+ private val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ mutableIsPlaying.value = isPlaying
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ mutablePlaybackState.value = playbackState.toPlaybackState()
+ mutableDuration.value = player.duration
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ mutableCurrentMediaItem.value = mediaItem?.let(MediaItemFactory::fromMediaItem)
+ mutableCurrentItemIndex.value = player.currentMediaItemIndex
+ }
+
+ override fun onRepeatModeChanged(repeatMode: Int) {
+ mutableRepeatMode.value = repeatMode.toRepeatMode()
+ }
+
+ override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
+ mutableShuffleModeEnabled.value = shuffleModeEnabled
+ }
+
+ override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) {
+ mutablePlaybackSpeed.value = playbackParameters.speed
+ mutablePlaybackPitch.value = playbackParameters.pitch
+ }
+ }
+
+ init {
+ player.addListener(listener)
+ scope.launch {
+ while (isActive) {
+ mutableCurrentPosition.value = player.currentPosition
+ mutableBufferedPosition.value = player.bufferedPosition
+ mutableDuration.value = player.duration
+ delay(250)
+ }
+ }
+ }
+
+ override val nativePlayer: Any
+ get() = player
+
+ override fun getCurrentPositionRealtime(): Long = player.currentPosition
+ override fun play() = player.play()
+ override fun pause() = player.pause()
+ override fun stop() = player.stop()
+ override fun prepare() = player.prepare()
+ override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
+ override fun seekToItem(index: Int, positionMs: Long) = player.seekTo(index, positionMs)
+ override fun seekToPrevious() = player.seekToPreviousMediaItem()
+ override fun seekToNext() = player.seekToNextMediaItem()
+
+ override fun setQueue(items: List, startIndex: Int) {
+ player.setMediaItems(items.map(MediaItemFactory::toMediaItem), startIndex, 0)
+ }
+
+ override fun setRepeatMode(mode: RepeatMode) {
+ player.repeatMode = when (mode) {
+ RepeatMode.OFF -> Player.REPEAT_MODE_OFF
+ RepeatMode.ONE -> Player.REPEAT_MODE_ONE
+ RepeatMode.ALL -> Player.REPEAT_MODE_ALL
+ }
+ }
+
+ override fun setShuffleModeEnabled(enabled: Boolean) {
+ player.shuffleModeEnabled = enabled
+ }
+
+ override fun setPlaybackParameters(speed: Float, pitch: Float) {
+ player.setPlaybackParameters(androidx.media3.common.PlaybackParameters(speed, pitch))
+ }
+
+ override fun applyPlaybackMode(mode: PlaybackMode) {
+ val exoPlayer = player as? ExoPlayer ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, mode == PlaybackMode.AUDIO_ONLY)
+ .build()
+ }
+
+ override fun selectResolution(resolution: ResolutionInfo) {
+ val exoPlayer = player as? ExoPlayer ?: return
+ val group = exoPlayer.currentTracks.groups.firstOrNull {
+ it.type == C.TRACK_TYPE_VIDEO && (0 until it.length).any { index ->
+ it.getTrackFormat(index).height == resolution.height
+ }
+ } ?: return
+ val index = (0 until group.length).firstOrNull {
+ group.getTrackFormat(it).height == resolution.height
+ } ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, index))
+ .build()
+ }
+
+ override fun selectSubtitle(subtitle: SubtitleInfo) {
+ val exoPlayer = player as? ExoPlayer ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .setPreferredTextLanguage(subtitle.language)
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
+ .build()
+ }
+
+ override fun applyDefaultResolution(defaultResolution: String) {
+ }
+
+ override fun clearResolutionOverride() {
+ val exoPlayer = player as? ExoPlayer ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .clearOverridesOfType(C.TRACK_TYPE_VIDEO)
+ .build()
+ }
+
+ override fun selectAudioLanguage(language: String) {
+ val exoPlayer = player as? ExoPlayer ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .setPreferredAudioLanguage(language)
+ .build()
+ }
+
+ override fun disableSubtitles() {
+ val exoPlayer = player as? ExoPlayer ?: return
+ exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters.buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
+ .build()
+ }
+
+ override fun stopService() = onStopService()
+ override fun syncQueueShuffle() {
+ val currentUuid = mutableCurrentMediaItem.value?.uuid
+ val currentPosition = player.currentPosition
+ val queue = SharedContext.queueManager.getCurrentQueue()
+ val currentIndex = queue.indexOfFirst { it.uuid == currentUuid }.coerceAtLeast(0)
+ player.setMediaItems(queue.map(MediaItemFactory::toMediaItem), currentIndex, currentPosition)
+ player.prepare()
+ }
+ override fun syncQueueClear() = player.clearMediaItems()
+ override fun syncQueueRemove(index: Int) = player.removeMediaItem(index)
+ override fun syncQueueAppend(item: PlatformMediaItem) = player.addMediaItem(MediaItemFactory.toMediaItem(item))
+ override fun syncQueueMove(from: Int, to: Int) = player.moveMediaItem(from, to)
+
+ fun release() {
+ player.removeListener(listener)
+ scope.cancel()
+ }
+
+ private fun Int.toPlaybackState(): PlaybackState = when (this) {
+ Player.STATE_BUFFERING -> PlaybackState.BUFFERING
+ Player.STATE_READY -> PlaybackState.READY
+ Player.STATE_ENDED -> PlaybackState.ENDED
+ else -> PlaybackState.IDLE
+ }
+
+ private fun Int.toRepeatMode(): RepeatMode = when (this) {
+ Player.REPEAT_MODE_ONE -> RepeatMode.ONE
+ Player.REPEAT_MODE_ALL -> RepeatMode.ALL
+ else -> RepeatMode.OFF
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/platform/PlatformMediaController.kt b/app/src/main/java/project/pipepipe/app/platform/PlatformMediaController.kt
new file mode 100644
index 000000000..6725d5937
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/platform/PlatformMediaController.kt
@@ -0,0 +1,95 @@
+package project.pipepipe.app.platform
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import project.pipepipe.app.PlaybackMode
+
+data class SubtitleCue(
+ val text: String,
+ val line: Float? = null,
+ val position: Float? = null
+)
+
+data class ResolutionInfo(
+ val height: Int,
+ val width: Int,
+ val codec: String?,
+ val frameRate: Float,
+ val isSelected: Boolean,
+ val isHdr: Boolean = false
+)
+
+data class SubtitleInfo(
+ val language: String,
+ val isSelected: Boolean,
+ val isAutoGenerated: Boolean = false
+)
+
+data class AudioLanguageInfo(
+ val language: String,
+ val isDefault: Boolean,
+ val isSelected: Boolean
+)
+
+interface PlatformMediaController {
+ val scope: CoroutineScope
+ val isPlaying: StateFlow
+ val currentPosition: StateFlow
+ val duration: StateFlow
+ val playbackState: StateFlow
+ val currentSubtitles: StateFlow>
+ val currentMediaItem: StateFlow
+ val currentItemIndex: StateFlow
+ val repeatMode: StateFlow
+ val shuffleModeEnabled: StateFlow
+ val playbackSpeed: StateFlow
+ val playbackPitch: StateFlow
+ val bufferedPosition: StateFlow
+ val availableResolutions: StateFlow>
+ val availableSubtitles: StateFlow>
+ val availableAudioLanguages: StateFlow>
+ val currentAudioLanguage: StateFlow
+ val nativePlayer: Any
+ val supportsAutoResolution: Boolean
+ get() = true
+
+ fun getCurrentPositionRealtime(): Long
+ fun play()
+ fun pause()
+ fun stop()
+ fun prepare()
+ fun seekTo(positionMs: Long)
+ fun seekToItem(index: Int, positionMs: Long = 0)
+ fun seekToPrevious()
+ fun seekToNext()
+ fun setQueue(items: List, startIndex: Int)
+ fun setRepeatMode(mode: RepeatMode)
+ fun setShuffleModeEnabled(enabled: Boolean)
+ fun setPlaybackParameters(speed: Float, pitch: Float)
+ fun applyPlaybackMode(mode: PlaybackMode)
+ fun selectResolution(resolution: ResolutionInfo)
+ fun selectSubtitle(subtitle: SubtitleInfo)
+ fun applyDefaultResolution(defaultResolution: String)
+ fun clearResolutionOverride()
+ fun selectAudioLanguage(language: String)
+ fun disableSubtitles()
+ fun stopService()
+ fun syncQueueShuffle()
+ fun syncQueueClear()
+ fun syncQueueRemove(index: Int)
+ fun syncQueueAppend(item: PlatformMediaItem)
+ fun syncQueueMove(from: Int, to: Int)
+}
+
+enum class PlaybackState {
+ IDLE,
+ BUFFERING,
+ READY,
+ ENDED
+}
+
+enum class RepeatMode {
+ OFF,
+ ONE,
+ ALL
+}
diff --git a/app/src/main/java/project/pipepipe/app/platform/PlatformMediaItem.kt b/app/src/main/java/project/pipepipe/app/platform/PlatformMediaItem.kt
new file mode 100644
index 000000000..ba3e851cf
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/platform/PlatformMediaItem.kt
@@ -0,0 +1,14 @@
+package project.pipepipe.app.platform
+
+import java.util.UUID
+
+data class PlatformMediaItem(
+ val mediaId: String,
+ val title: String?,
+ val artist: String?,
+ val artworkUrl: String?,
+ val durationMs: Long?,
+ val serviceId: Int?,
+ val extras: Map? = null,
+ val uuid: String = UUID.randomUUID().toString()
+)
diff --git a/app/src/main/java/project/pipepipe/app/popup/PopupPlayerManager.kt b/app/src/main/java/project/pipepipe/app/popup/PopupPlayerManager.kt
new file mode 100644
index 000000000..a18ce04db
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/popup/PopupPlayerManager.kt
@@ -0,0 +1,186 @@
+package project.pipepipe.app.popup
+
+import android.content.Context
+import android.graphics.PixelFormat
+import android.util.DisplayMetrics
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.WindowManager
+import android.widget.FrameLayout
+import androidx.media3.common.Player
+import androidx.media3.ui.PlayerView
+import androidx.preference.PreferenceManager
+import org.schabi.newpipe.R
+import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding
+import org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS
+import org.schabi.newpipe.player.helper.PlayerHelper
+import project.pipepipe.app.platform.PlatformMediaController
+import kotlin.math.hypot
+
+class PopupPlayerManager(
+ private val context: Context,
+ private val controller: PlatformMediaController,
+ private val onClose: () -> Unit
+) {
+ private val windowManager = context.getSystemService(WindowManager::class.java)
+ private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ private var popupView: FrameLayout? = null
+ private var closeOverlay: PlayerPopupCloseOverlayBinding? = null
+ private var layoutParams: WindowManager.LayoutParams? = null
+ private var screenWidth = 0
+ private var screenHeight = 0
+ private var initialX = 0
+ private var initialY = 0
+ private var initialRawX = 0f
+ private var initialRawY = 0f
+ private var initialDistance = 0.0
+ private var initialWidth = 0
+
+ fun show() {
+ if (popupView != null) {
+ return
+ }
+ updateScreenSize()
+ val width = preferences.getFloat(
+ context.getString(R.string.popup_saved_width_key),
+ context.resources.getDimension(R.dimen.popup_default_width)
+ ).toInt()
+ val height = PlayerHelper.getMinimumVideoHeight(width.toFloat()).toInt()
+ layoutParams = WindowManager.LayoutParams(
+ width,
+ height,
+ PlayerHelper.popupLayoutParamType(),
+ IDLE_WINDOW_FLAGS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.LEFT or Gravity.TOP
+ x = preferences.getInt(
+ context.getString(R.string.popup_saved_x_key),
+ screenWidth / 2 - width / 2
+ )
+ y = preferences.getInt(
+ context.getString(R.string.popup_saved_y_key),
+ screenHeight / 2 - height / 2
+ )
+ }
+ checkBounds()
+ closeOverlay = PlayerPopupCloseOverlayBinding.inflate(android.view.LayoutInflater.from(context))
+ closeOverlay?.closeButton?.visibility = View.GONE
+ windowManager.addView(closeOverlay?.root, PlayerHelper.buildCloseOverlayLayoutParams())
+ popupView = FrameLayout(context).apply {
+ addView(
+ PlayerView(context).apply {
+ useController = false
+ player = controller.nativePlayer as Player
+ setOnTouchListener(::onTouch)
+ },
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT
+ )
+ )
+ }
+ windowManager.addView(popupView, layoutParams)
+ }
+
+ fun remove() {
+ popupView?.let {
+ runCatching { windowManager.removeView(it) }
+ }
+ closeOverlay?.root?.let {
+ runCatching { windowManager.removeView(it) }
+ }
+ popupView = null
+ closeOverlay = null
+ layoutParams = null
+ }
+
+ private fun onTouch(view: View, event: MotionEvent): Boolean {
+ val params = layoutParams ?: return false
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ initialX = params.x
+ initialY = params.y
+ initialRawX = event.rawX
+ initialRawY = event.rawY
+ closeOverlay?.closeButton?.visibility = View.VISIBLE
+ }
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ if (event.pointerCount == 2) {
+ initialDistance = pointerDistance(event)
+ initialWidth = params.width
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (event.pointerCount == 2 && initialDistance > 0) {
+ resize((initialWidth * pointerDistance(event) / initialDistance).toInt())
+ } else {
+ params.x = initialX + (event.rawX - initialRawX).toInt()
+ params.y = initialY + (event.rawY - initialRawY).toInt()
+ checkBounds()
+ windowManager.updateViewLayout(view, params)
+ }
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ if (isInsideCloseButton(event.rawX, event.rawY)) {
+ remove()
+ onClose()
+ } else {
+ savePosition()
+ closeOverlay?.closeButton?.visibility = View.GONE
+ }
+ initialDistance = 0.0
+ }
+ }
+ return true
+ }
+
+ private fun resize(width: Int) {
+ val params = layoutParams ?: return
+ val minimumWidth = context.resources.getDimension(R.dimen.popup_minimum_width).toInt()
+ params.width = width.coerceIn(minimumWidth, screenWidth)
+ params.height = PlayerHelper.getMinimumVideoHeight(params.width.toFloat()).toInt()
+ checkBounds()
+ popupView?.let { windowManager.updateViewLayout(it, params) }
+ }
+
+ private fun checkBounds() {
+ layoutParams?.apply {
+ x = x.coerceIn(0, (screenWidth - width).coerceAtLeast(0))
+ y = y.coerceIn(0, (screenHeight - height).coerceAtLeast(0))
+ }
+ }
+
+ private fun savePosition() {
+ layoutParams?.let {
+ preferences.edit()
+ .putFloat(context.getString(R.string.popup_saved_width_key), it.width.toFloat())
+ .putInt(context.getString(R.string.popup_saved_x_key), it.x)
+ .putInt(context.getString(R.string.popup_saved_y_key), it.y)
+ .apply()
+ }
+ }
+
+ private fun updateScreenSize() {
+ val metrics = DisplayMetrics()
+ windowManager.defaultDisplay.getMetrics(metrics)
+ screenWidth = metrics.widthPixels
+ screenHeight = metrics.heightPixels
+ }
+
+ private fun pointerDistance(event: MotionEvent): Double =
+ hypot(
+ (event.getX(0) - event.getX(1)).toDouble(),
+ (event.getY(0) - event.getY(1)).toDouble()
+ )
+
+ private fun isInsideCloseButton(x: Float, y: Float): Boolean {
+ val button = closeOverlay?.closeButton ?: return false
+ val location = IntArray(2)
+ button.getLocationOnScreen(location)
+ val centerX = location[0] + button.width / 2f
+ val centerY = location[1] + button.height / 2f
+ return hypot((x - centerX).toDouble(), (y - centerY).toDouble()) <= button.width
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt b/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt
new file mode 100644
index 000000000..b269d5d19
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt
@@ -0,0 +1,80 @@
+package project.pipepipe.app.service
+
+import android.content.Intent
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.MediaSession
+import androidx.preference.PreferenceManager
+import project.pipepipe.app.SharedContext
+import project.pipepipe.app.mediasource.ExtractorMediaSourceFactory
+import project.pipepipe.app.platform.AndroidMediaController
+import project.pipepipe.app.popup.PopupPlayerManager
+
+@UnstableApi
+class PlaybackService : MediaLibraryService() {
+ private var session: MediaLibrarySession? = null
+ private var controller: AndroidMediaController? = null
+ private var popupPlayerManager: PopupPlayerManager? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ if (!PreferenceManager.getDefaultSharedPreferences(this)
+ .getBoolean("use_experimental_new_ui", false)
+ ) {
+ stopSelf()
+ return
+ }
+ val player = ExoPlayer.Builder(this)
+ .setMediaSourceFactory(ExtractorMediaSourceFactory(this))
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .setUsage(C.USAGE_MEDIA)
+ .build(),
+ true
+ )
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+ controller = AndroidMediaController(player, ::stopSelf)
+ SharedContext.platformMediaController = controller
+ popupPlayerManager = PopupPlayerManager(this, controller!!, ::stopSelf)
+ session = MediaLibrarySession.Builder(this, player, object : MediaLibrarySession.Callback {})
+ .build()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_SHOW_POPUP) {
+ popupPlayerManager?.show()
+ }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ if (controller?.isPlaying?.value != true) {
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ if (SharedContext.platformMediaController === controller) {
+ SharedContext.platformMediaController = null
+ }
+ controller?.release()
+ popupPlayerManager?.remove()
+ session?.player?.release()
+ session?.release()
+ controller = null
+ popupPlayerManager = null
+ session = null
+ super.onDestroy()
+ }
+
+ companion object {
+ const val ACTION_SHOW_POPUP = "project.pipepipe.app.service.SHOW_POPUP"
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt
new file mode 100644
index 000000000..3b396d0f7
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt
@@ -0,0 +1,102 @@
+package project.pipepipe.app.ui
+
+import android.view.View
+import android.content.Intent
+import android.view.Gravity
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.ui.platform.ComposeView
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.schabi.newpipe.info_list.PipePipeComposeTheme
+import org.schabi.newpipe.util.ThemeHelper
+import project.pipepipe.app.service.PlaybackService
+import project.pipepipe.app.SharedContext
+import project.pipepipe.app.ui.screens.videodetail.VideoDetailScreen
+import project.pipepipe.app.uistate.VideoDetailPageState
+import project.pipepipe.app.viewmodel.VideoDetailViewModel
+
+object ExperimentalVideoDetailHost {
+ val viewModel = VideoDetailViewModel()
+
+ @JvmStatic
+ fun attach(activity: AppCompatActivity, view: ComposeView) {
+ if (!ThemeHelper.shouldUseExperimentalNewUi(activity)) {
+ view.visibility = View.GONE
+ return
+ }
+ activity.startService(Intent(activity, PlaybackService::class.java))
+ view.setContent {
+ PipePipeComposeTheme(activity) {
+ VideoDetailScreen(viewModel)
+ }
+ }
+ activity.lifecycleScope.launch {
+ viewModel.uiState.collectLatest {
+ view.visibility =
+ if (it.pageState == project.pipepipe.app.uistate.VideoDetailPageState.HIDDEN) {
+ View.GONE
+ } else {
+ View.VISIBLE
+ }
+ if (it.pageState == project.pipepipe.app.uistate.VideoDetailPageState.BOTTOM_PLAYER) {
+ delay(300)
+ if (viewModel.uiState.value.pageState
+ == project.pipepipe.app.uistate.VideoDetailPageState.BOTTOM_PLAYER
+ ) {
+ view.layoutParams = view.layoutParams.apply {
+ height = (64 * view.resources.displayMetrics.density).toInt()
+ if (this is CoordinatorLayout.LayoutParams) {
+ gravity = Gravity.BOTTOM
+ }
+ }
+ }
+ } else {
+ view.layoutParams = view.layoutParams.apply {
+ height = ViewGroup.LayoutParams.MATCH_PARENT
+ if (this is CoordinatorLayout.LayoutParams) {
+ gravity = Gravity.NO_GRAVITY
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @JvmStatic
+ fun open(serviceId: Int, url: String) {
+ viewModel.open(serviceId, url)
+ }
+
+ @JvmStatic
+ fun expand() {
+ viewModel.showDetail()
+ }
+
+ @JvmStatic
+ fun showBottomPlayer() {
+ viewModel.showBottomPlayer()
+ }
+
+ @JvmStatic
+ fun onBackPressed(): Boolean = when (viewModel.uiState.value.pageState) {
+ VideoDetailPageState.FULLSCREEN_PLAYER -> {
+ viewModel.showDetail()
+ true
+ }
+ VideoDetailPageState.DETAIL_PAGE -> {
+ if (!viewModel.navigateBack()) {
+ if (SharedContext.platformMediaController?.currentMediaItem?.value != null) {
+ viewModel.showBottomPlayer()
+ } else {
+ viewModel.hide()
+ }
+ }
+ true
+ }
+ else -> false
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/ui/screens/videodetail/VideoDetailScreen.kt b/app/src/main/java/project/pipepipe/app/ui/screens/videodetail/VideoDetailScreen.kt
new file mode 100644
index 000000000..381f383a3
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/ui/screens/videodetail/VideoDetailScreen.kt
@@ -0,0 +1,229 @@
+package project.pipepipe.app.ui.screens.videodetail
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Fullscreen
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.ui.PlayerView
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.info_list.CommonItem
+import org.schabi.newpipe.info_list.buildInfoItemState
+import org.schabi.newpipe.util.Localization
+import project.pipepipe.app.SharedContext
+import project.pipepipe.app.mediasource.MediaItemFactory
+import project.pipepipe.app.uistate.VideoDetailPageState
+import project.pipepipe.app.viewmodel.VideoDetailViewModel
+
+@UnstableApi
+@Composable
+fun VideoDetailScreen(viewModel: VideoDetailViewModel) {
+ val state by viewModel.uiState.collectAsState()
+ val controller by SharedContext.mediaController.collectAsState()
+ val density = LocalDensity.current
+ val context = LocalContext.current
+ var dragDistance by remember { mutableFloatStateOf(0f) }
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val offset by animateDpAsState(
+ if (state.pageState == VideoDetailPageState.BOTTOM_PLAYER) maxHeight - 64.dp else 0.dp,
+ label = "detailOffset"
+ )
+ when {
+ state.isLoading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
+ state.error != null -> Text(
+ state.error?.message ?: state.error.toString(),
+ Modifier.align(Alignment.Center).padding(24.dp)
+ )
+ state.currentStreamInfo != null -> {
+ val streamInfo = state.currentStreamInfo!!
+ Column(
+ Modifier
+ .fillMaxSize()
+ .offset(y = offset)
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .background(androidx.compose.ui.graphics.Color.Black)
+ .pointerInput(Unit) {
+ detectVerticalDragGestures(
+ onDragEnd = {
+ when {
+ dragDistance > with(density) { 100.dp.toPx() } ->
+ viewModel.showBottomPlayer()
+ dragDistance < -with(density) { 50.dp.toPx() } ->
+ viewModel.showFullscreen()
+ }
+ dragDistance = 0f
+ },
+ onDragCancel = { dragDistance = 0f }
+ ) { change, amount ->
+ change.consume()
+ dragDistance += amount
+ }
+ }
+ ) {
+ controller?.let {
+ AndroidView(
+ factory = { context ->
+ PlayerView(context).apply {
+ useController = false
+ player = it.nativePlayer as Player
+ }
+ },
+ update = { view -> view.player = it.nativePlayer as Player },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ Row(
+ Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ IconButton(onClick = {
+ val playbackController = controller ?: return@IconButton
+ if (playbackController.currentMediaItem.value?.mediaId != streamInfo.url) {
+ val item = MediaItemFactory.fromStreamInfo(streamInfo)
+ SharedContext.queueManager.setQueue(listOf(item))
+ playbackController.prepare()
+ }
+ if (playbackController.isPlaying.value) {
+ playbackController.pause()
+ } else {
+ playbackController.play()
+ }
+ }) {
+ Icon(
+ if (controller?.isPlaying?.collectAsState()?.value == true) {
+ Icons.Default.Pause
+ } else {
+ Icons.Default.PlayArrow
+ },
+ null
+ )
+ }
+ IconButton(onClick = viewModel::showFullscreen) {
+ Icon(Icons.Default.Fullscreen, null)
+ }
+ IconButton(onClick = viewModel::showBottomPlayer) {
+ Icon(Icons.Default.KeyboardArrowDown, null)
+ }
+ }
+ }
+ LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
+ item {
+ Text(
+ streamInfo.name,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(16.dp)
+ )
+ Text(
+ streamInfo.uploaderName ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ if (streamInfo.viewCount >= 0) {
+ Text(
+ Localization.shortViewCount(context, streamInfo.viewCount),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+ streamInfo.description?.content?.takeIf(String::isNotBlank)?.let {
+ Text(
+ stringResource(R.string.description_tab_description),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ Text(
+ it,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+ val relatedItems = streamInfo.relatedItems.filterIsInstance()
+ if (relatedItems.isNotEmpty()) {
+ item {
+ Text(
+ stringResource(R.string.related_items_tab_description),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ items(relatedItems) { item ->
+ buildInfoItemState(context, item, null)?.let { itemState ->
+ CommonItem(
+ state = itemState,
+ isGridLayout = false,
+ isCardLayout = false,
+ showDragHandle = false,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { viewModel.open(item.serviceId, item.url) }
+ )
+ }
+ }
+ }
+ }
+ }
+ if (state.pageState == VideoDetailPageState.BOTTOM_PLAYER) {
+ Row(
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .height(64.dp)
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable(onClick = viewModel::showDetail),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(streamInfo.name, Modifier.weight(1f).padding(16.dp), maxLines = 1)
+ IconButton(onClick = viewModel::hide) {
+ Icon(Icons.Default.Close, null)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/project/pipepipe/app/uistate/VideoDetailPageState.kt b/app/src/main/java/project/pipepipe/app/uistate/VideoDetailPageState.kt
new file mode 100644
index 000000000..3bd5d50e1
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/uistate/VideoDetailPageState.kt
@@ -0,0 +1,8 @@
+package project.pipepipe.app.uistate
+
+enum class VideoDetailPageState {
+ HIDDEN,
+ BOTTOM_PLAYER,
+ DETAIL_PAGE,
+ FULLSCREEN_PLAYER
+}
diff --git a/app/src/main/java/project/pipepipe/app/uistate/VideoDetailUiState.kt b/app/src/main/java/project/pipepipe/app/uistate/VideoDetailUiState.kt
new file mode 100644
index 000000000..910473f6b
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/uistate/VideoDetailUiState.kt
@@ -0,0 +1,13 @@
+package project.pipepipe.app.uistate
+
+import org.schabi.newpipe.extractor.stream.StreamInfo
+
+data class VideoDetailUiState(
+ val streamInfoStack: List = emptyList(),
+ val pageState: VideoDetailPageState = VideoDetailPageState.HIDDEN,
+ val isLoading: Boolean = false,
+ val error: Throwable? = null
+) {
+ val currentStreamInfo: StreamInfo?
+ get() = streamInfoStack.lastOrNull()
+}
diff --git a/app/src/main/java/project/pipepipe/app/viewmodel/VideoDetailViewModel.kt b/app/src/main/java/project/pipepipe/app/viewmodel/VideoDetailViewModel.kt
new file mode 100644
index 000000000..2e430ce78
--- /dev/null
+++ b/app/src/main/java/project/pipepipe/app/viewmodel/VideoDetailViewModel.kt
@@ -0,0 +1,72 @@
+package project.pipepipe.app.viewmodel
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.schabi.newpipe.util.ExtractorHelper
+import project.pipepipe.app.mediasource.MediaItemFactory
+import project.pipepipe.app.uistate.VideoDetailPageState
+import project.pipepipe.app.uistate.VideoDetailUiState
+
+class VideoDetailViewModel {
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private val mutableUiState = MutableStateFlow(VideoDetailUiState())
+ val uiState: StateFlow = mutableUiState.asStateFlow()
+
+ fun open(serviceId: Int, url: String) {
+ if (mutableUiState.value.currentStreamInfo?.url == url) {
+ showDetail()
+ return
+ }
+ mutableUiState.value = mutableUiState.value.copy(
+ pageState = VideoDetailPageState.DETAIL_PAGE,
+ isLoading = true,
+ error = null
+ )
+ scope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ ExtractorHelper.getStreamInfo(serviceId, url, false).blockingGet()
+ }
+ }.onSuccess {
+ MediaItemFactory.fromStreamInfo(it)
+ mutableUiState.value = mutableUiState.value.copy(
+ streamInfoStack = mutableUiState.value.streamInfoStack + it,
+ isLoading = false
+ )
+ }.onFailure {
+ mutableUiState.value = mutableUiState.value.copy(isLoading = false, error = it)
+ }
+ }
+ }
+
+ fun navigateBack(): Boolean {
+ val state = mutableUiState.value
+ if (state.streamInfoStack.size <= 1) {
+ return false
+ }
+ mutableUiState.value = state.copy(streamInfoStack = state.streamInfoStack.dropLast(1))
+ return true
+ }
+
+ fun showDetail() {
+ mutableUiState.value = mutableUiState.value.copy(pageState = VideoDetailPageState.DETAIL_PAGE)
+ }
+
+ fun showBottomPlayer() {
+ mutableUiState.value = mutableUiState.value.copy(pageState = VideoDetailPageState.BOTTOM_PLAYER)
+ }
+
+ fun showFullscreen() {
+ mutableUiState.value = mutableUiState.value.copy(pageState = VideoDetailPageState.FULLSCREEN_PLAYER)
+ }
+
+ fun hide() {
+ mutableUiState.value = VideoDetailUiState()
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 97ccd199e..a5a9be159 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -27,6 +27,12 @@
app:behavior_peekHeight="0dp"
app:layout_behavior="org.schabi.newpipe.player.event.CustomBottomSheetBehavior" />
+
+