From 25fd494889168b9787192b2f81185f3c109cebf5 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:39:50 +0800 Subject: [PATCH 01/14] build: prepare dependencies for experimental media3 player --- app/build.gradle | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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}" From 1e4a6d0df66d07a953ba35a2e475fbf85c6d06ec Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:41:43 +0800 Subject: [PATCH 02/14] player: add experimental playback state model --- app/build.gradle | 1 + .../java/project/pipepipe/app/PlaybackMode.kt | 7 ++ .../java/project/pipepipe/app/QueueManager.kt | 101 ++++++++++++++++++ .../project/pipepipe/app/SharedContext.kt | 24 +++++ .../app/platform/PlatformMediaController.kt | 95 ++++++++++++++++ .../app/platform/PlatformMediaItem.kt | 14 +++ .../project/pipepipe/app/QueueManagerTest.kt | 58 ++++++++++ 7 files changed, 300 insertions(+) create mode 100644 app/src/main/java/project/pipepipe/app/PlaybackMode.kt create mode 100644 app/src/main/java/project/pipepipe/app/QueueManager.kt create mode 100644 app/src/main/java/project/pipepipe/app/SharedContext.kt create mode 100644 app/src/main/java/project/pipepipe/app/platform/PlatformMediaController.kt create mode 100644 app/src/main/java/project/pipepipe/app/platform/PlatformMediaItem.kt create mode 100644 app/src/test/java/project/pipepipe/app/QueueManagerTest.kt diff --git a/app/build.gradle b/app/build.gradle index 8c1ce4883..73c28e21b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -339,6 +339,7 @@ dependencies { // implementation 'com.arthenica:ffmpeg-kit-https:6.0-2.LTS' implementation project(':ffmpeg') implementation 'com.arthenica:smart-exception-java:0.2.1' + testImplementation 'junit:junit:4.13.2' } static String getGitWorkingBranch() { 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..472270b57 --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/SharedContext.kt @@ -0,0 +1,24 @@ +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() + + var platformMediaController: PlatformMediaController? = null + set(value) { + field = value + queueManager.attachController(value) + } + + fun updatePlaybackMode(mode: PlaybackMode) { + platformMediaController?.applyPlaybackMode(mode) + mutablePlaybackMode.value = mode + } +} 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/test/java/project/pipepipe/app/QueueManagerTest.kt b/app/src/test/java/project/pipepipe/app/QueueManagerTest.kt new file mode 100644 index 000000000..ae1cf3b12 --- /dev/null +++ b/app/src/test/java/project/pipepipe/app/QueueManagerTest.kt @@ -0,0 +1,58 @@ +package project.pipepipe.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import project.pipepipe.app.platform.PlatformMediaItem + +class QueueManagerTest { + private fun item(id: String) = PlatformMediaItem( + mediaId = id, + title = id, + artist = null, + artworkUrl = null, + durationMs = null, + serviceId = null, + uuid = id + ) + + @Test + fun queueOperationsKeepExpectedOrder() { + val manager = QueueManager() + manager.setQueue(listOf(item("a"), item("b"), item("c")), notifyOnly = true) + manager.moveItem(2, 1) + manager.removeItemByUuid("a") + manager.addItem(item("d")) + + assertEquals(listOf("c", "b", "d"), manager.getCurrentQueue().map { it.uuid }) + } + + @Test + fun shuffleCanRestoreOriginalOrder() { + val manager = QueueManager() + manager.setQueue(listOf(item("a"), item("b"), item("c")), notifyOnly = true) + manager.shuffle("b") + + assertTrue(manager.isShuffled()) + assertEquals("b", manager.getCurrentQueue().first().uuid) + + manager.unshuffle() + + assertFalse(manager.isShuffled()) + assertEquals(listOf("a", "b", "c"), manager.getCurrentQueue().map { it.uuid }) + } + + @Test + fun extrasAreMerged() { + val manager = QueueManager() + manager.setQueue( + listOf(item("a").copy(extras = mapOf("first" to 1))), + notifyOnly = true + ) + + manager.updateItemExtras("a", mapOf("second" to 2)) + + assertEquals(mapOf("first" to 1, "second" to 2), manager.getCurrentQueue().single().extras) + } +} From e6131085e53f143bcbceaec6aea3a19f8c349e2e Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:44:40 +0800 Subject: [PATCH 03/14] player: add media3 sources for extractor streams --- .../ExtractorMediaSourceFactory.kt | 120 ++++++++++++++++++ .../app/mediasource/MediaItemFactory.kt | 57 +++++++++ .../app/mediasource/StreamInfoRepository.kt | 22 ++++ .../app/mediasource/MediaItemFactoryTest.kt | 20 +++ 4 files changed, 219 insertions(+) create mode 100644 app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt create mode 100644 app/src/main/java/project/pipepipe/app/mediasource/MediaItemFactory.kt create mode 100644 app/src/main/java/project/pipepipe/app/mediasource/StreamInfoRepository.kt create mode 100644 app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt 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..5f373de01 --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt @@ -0,0 +1,120 @@ +package project.pipepipe.app.mediasource + +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.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.VideoStream +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets + +@UnstableApi +class ExtractorMediaSourceFactory( + private val dataSourceFactory: DefaultDataSource.Factory +) : MediaSource.Factory { + constructor(context: android.content.Context) : this( + DefaultDataSource.Factory( + context, + DefaultHttpDataSource.Factory() + .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 = requireNotNull(StreamInfoRepository.get(mediaItem.mediaId)) + 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)) } + if (video == null || video.isVideoOnly()) { + audio?.let { add(createStreamSource(mediaItem, it)) } + } + } + 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): MediaSource = + 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 createDashSource(mediaItem: MediaItem, stream: Stream): MediaSource { + val factory = DashMediaSource.Factory(dataSourceFactory) + if (stream.isUrl) { + return factory.createMediaSource(mediaItem.withUri(stream.content, MimeTypes.APPLICATION_MPD)) + } + val manifestUri = Uri.parse(stream.manifestUrl ?: "") + val manifest = DashManifestParser().parse( + manifestUri, + ByteArrayInputStream(stream.content.toByteArray(StandardCharsets.UTF_8)) + ) + return factory.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/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt b/app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt new file mode 100644 index 000000000..6d5ad8843 --- /dev/null +++ b/app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt @@ -0,0 +1,20 @@ +package project.pipepipe.app.mediasource + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.schabi.newpipe.extractor.stream.StreamInfo + +class MediaItemFactoryTest { + @Test + fun streamInfoIsRegisteredAndConverted() { + val streamInfo = StreamInfo(1, "id", "https://example.com/video", "title") + + val item = MediaItemFactory.fromStreamInfo(streamInfo, "uuid") + + assertEquals(streamInfo.url, item.mediaId) + assertEquals(streamInfo.name, item.title) + assertEquals("uuid", item.uuid) + assertSame(streamInfo, StreamInfoRepository.get(streamInfo.url)) + } +} From 323ed0904c7b86f39fab16fb3c48378cde2f9614 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:46:32 +0800 Subject: [PATCH 04/14] player: add experimental media3 playback service --- app/src/main/AndroidManifest.xml | 10 + .../app/platform/AndroidMediaController.kt | 217 ++++++++++++++++++ .../pipepipe/app/service/PlaybackService.kt | 64 ++++++ 3 files changed, 291 insertions(+) create mode 100644 app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt create mode 100644 app/src/main/java/project/pipepipe/app/service/PlaybackService.kt 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"> + + + + + + + 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() = Unit + 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/service/PlaybackService.kt b/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt new file mode 100644 index 000000000..758253eb8 --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt @@ -0,0 +1,64 @@ +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 + +@UnstableApi +class PlaybackService : MediaLibraryService() { + private var session: MediaLibrarySession? = null + private var controller: AndroidMediaController? = 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 + session = MediaLibrarySession.Builder(this, player, object : MediaLibrarySession.Callback {}) + .build() + } + + 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() + session?.player?.release() + session?.release() + controller = null + session = null + super.onDestroy() + } +} From f4043756c2974329d92fa34d5bafe014383bb156 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:47:54 +0800 Subject: [PATCH 05/14] ui: add experimental video detail compose host --- .../java/org/schabi/newpipe/MainActivity.java | 3 + .../app/ui/ExperimentalVideoDetailHost.kt | 66 +++++++++++++++++++ .../app/uistate/VideoDetailPageState.kt | 8 +++ app/src/main/res/layout/activity_main.xml | 6 ++ 4 files changed, 83 insertions(+) create mode 100644 app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt create mode 100644 app/src/main/java/project/pipepipe/app/uistate/VideoDetailPageState.kt diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 7c6102c8c..9daa6e965 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -93,6 +93,8 @@ import javax.net.ssl.SSLSession; import javax.net.ssl.X509TrustManager; +import project.pipepipe.app.ui.ExperimentalVideoDetailHost; + public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; @SuppressWarnings("ConstantConditions") @@ -151,6 +153,7 @@ protected void onCreate(final Bundle savedInstanceState) { .getHeaderView(0)); toolbarLayoutBinding = mainBinding.toolbarLayout; setContentView(mainBinding.getRoot()); + ExperimentalVideoDetailHost.attach(this, mainBinding.experimentalVideoDetailHost); if (getSupportFragmentManager().getBackStackEntryCount() == 0) { initFragments(); 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..ecb2b54f0 --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt @@ -0,0 +1,66 @@ +package project.pipepipe.app.ui + +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.schabi.newpipe.info_list.PipePipeComposeTheme +import org.schabi.newpipe.util.ThemeHelper +import project.pipepipe.app.uistate.VideoDetailPageState + +object ExperimentalVideoDetailHost { + private val mutablePageState = MutableStateFlow(VideoDetailPageState.HIDDEN) + val pageState: StateFlow = mutablePageState.asStateFlow() + + @JvmStatic + fun attach(activity: AppCompatActivity, view: ComposeView) { + if (!ThemeHelper.shouldUseExperimentalNewUi(activity)) { + view.visibility = View.GONE + return + } + view.setContent { + PipePipeComposeTheme(activity) { + Content() + } + } + activity.lifecycleScope.launch { + pageState.collect { + view.visibility = if (it == VideoDetailPageState.HIDDEN) View.GONE else View.VISIBLE + } + } + } + + fun showDetail() { + mutablePageState.value = VideoDetailPageState.DETAIL_PAGE + } + + fun showBottomPlayer() { + mutablePageState.value = VideoDetailPageState.BOTTOM_PLAYER + } + + fun showFullscreen() { + mutablePageState.value = VideoDetailPageState.FULLSCREEN_PLAYER + } + + fun hide() { + mutablePageState.value = VideoDetailPageState.HIDDEN + } + + @Composable + private fun Content() { + val state by pageState.collectAsState() + if (state != VideoDetailPageState.HIDDEN) { + Box(Modifier.fillMaxSize()) + } + } +} 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/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" /> + + Date: Fri, 5 Jun 2026 16:50:20 +0800 Subject: [PATCH 06/14] ui: add experimental video detail and player --- .../project/pipepipe/app/SharedContext.kt | 4 + .../app/ui/ExperimentalVideoDetailHost.kt | 79 ++++---- .../screens/videodetail/VideoDetailScreen.kt | 174 ++++++++++++++++++ .../app/uistate/VideoDetailUiState.kt | 13 ++ .../app/viewmodel/VideoDetailViewModel.kt | 72 ++++++++ 5 files changed, 305 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/project/pipepipe/app/ui/screens/videodetail/VideoDetailScreen.kt create mode 100644 app/src/main/java/project/pipepipe/app/uistate/VideoDetailUiState.kt create mode 100644 app/src/main/java/project/pipepipe/app/viewmodel/VideoDetailViewModel.kt diff --git a/app/src/main/java/project/pipepipe/app/SharedContext.kt b/app/src/main/java/project/pipepipe/app/SharedContext.kt index 472270b57..744470949 100644 --- a/app/src/main/java/project/pipepipe/app/SharedContext.kt +++ b/app/src/main/java/project/pipepipe/app/SharedContext.kt @@ -11,9 +11,13 @@ object SharedContext { 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) } diff --git a/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt index ecb2b54f0..9c3f8baa8 100644 --- a/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt +++ b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt @@ -1,26 +1,24 @@ 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.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +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.uistate.VideoDetailPageState +import project.pipepipe.app.service.PlaybackService +import project.pipepipe.app.ui.screens.videodetail.VideoDetailScreen +import project.pipepipe.app.viewmodel.VideoDetailViewModel object ExperimentalVideoDetailHost { - private val mutablePageState = MutableStateFlow(VideoDetailPageState.HIDDEN) - val pageState: StateFlow = mutablePageState.asStateFlow() + val viewModel = VideoDetailViewModel() @JvmStatic fun attach(activity: AppCompatActivity, view: ComposeView) { @@ -28,39 +26,46 @@ object ExperimentalVideoDetailHost { view.visibility = View.GONE return } + activity.startService(Intent(activity, PlaybackService::class.java)) view.setContent { PipePipeComposeTheme(activity) { - Content() + VideoDetailScreen(viewModel) } } activity.lifecycleScope.launch { - pageState.collect { - view.visibility = if (it == VideoDetailPageState.HIDDEN) View.GONE else View.VISIBLE + 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 + } + } + } } } } - fun showDetail() { - mutablePageState.value = VideoDetailPageState.DETAIL_PAGE - } - - fun showBottomPlayer() { - mutablePageState.value = VideoDetailPageState.BOTTOM_PLAYER - } - - fun showFullscreen() { - mutablePageState.value = VideoDetailPageState.FULLSCREEN_PLAYER - } - - fun hide() { - mutablePageState.value = VideoDetailPageState.HIDDEN - } - - @Composable - private fun Content() { - val state by pageState.collectAsState() - if (state != VideoDetailPageState.HIDDEN) { - Box(Modifier.fillMaxSize()) - } + @JvmStatic + fun open(serviceId: Int, url: String) { + viewModel.open(serviceId, url) } } 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..7387a3e3a --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/ui/screens/videodetail/VideoDetailScreen.kt @@ -0,0 +1,174 @@ +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.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.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 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 + 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) + } + } + } + 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 (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/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() + } +} From 543c806ac514b5a97fe32b1bd1b9cbe0d12b167e Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:52:43 +0800 Subject: [PATCH 07/14] player: route experimental ui playback to media3 --- .../java/org/schabi/newpipe/MainActivity.java | 4 ++ .../schabi/newpipe/util/NavigationHelper.java | 33 ++++++++++ .../app/ExperimentalPlaybackRouter.kt | 61 +++++++++++++++++++ .../pipepipe/app/LegacyPlayQueueAdapter.kt | 17 ++++++ .../ExtractorMediaSourceFactory.kt | 7 ++- .../app/ui/ExperimentalVideoDetailHost.kt | 31 ++++++++++ 6 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt create mode 100644 app/src/main/java/project/pipepipe/app/LegacyPlayQueueAdapter.kt diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 9daa6e965..49afd4bfd 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -620,6 +620,10 @@ public boolean onKeyDown(final int keyCode, final KeyEvent event) { @Override public void onBackPressed() { + if (ThemeHelper.shouldUseExperimentalNewUi(this) + && ExperimentalVideoDetailHost.onBackPressed()) { + return; + } if (DEBUG) { Log.d(TAG, "onBackPressed() called"); } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 83dbf5e4b..15ec76b63 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -22,6 +22,7 @@ import com.jakewharton.processphoenix.ProcessPhoenix; +import org.schabi.newpipe.App; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; @@ -61,6 +62,10 @@ import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.external_communication.ShareUtils; +import project.pipepipe.app.ui.ExperimentalVideoDetailHost; +import project.pipepipe.app.ExperimentalPlaybackRouter; +import project.pipepipe.app.PlaybackMode; + import java.util.ArrayList; import java.util.Random; @@ -170,6 +175,10 @@ public static void playOnPopupPlayer(final Context context, public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.play(context, queue, PlaybackMode.AUDIO_ONLY, false); + return; + } Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) .show(); @@ -181,6 +190,10 @@ public static void playOnBackgroundPlayer(final Context context, public static void playOnBackgroundPlayerShuffled(final Context context, final PlayQueue queue, final boolean resumePlayback) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.play(context, queue, PlaybackMode.AUDIO_ONLY, true); + return; + } Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) .show(); queue.setIndex(new Random().nextInt(queue.getStreams().size())); @@ -195,6 +208,10 @@ public static void playOnBackgroundPlayerShuffled(final Context context, public static void enqueueOnPlayer(final Context context, final PlayQueue queue, final PlayerType playerType) { + if (ThemeHelper.shouldUseExperimentalNewUi(context) && playerType != PlayerType.POPUP) { + ExperimentalPlaybackRouter.enqueue(context, queue, false); + return; + } if ((playerType == PlayerType.POPUP) && !PermissionHelper.isPopupEnabled(context)) { PermissionHelper.showPopupEnablementToast(context); return; @@ -219,6 +236,10 @@ public static void enqueueOnPlayer(final Context context, final PlayQueue queue) /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.enqueue(context, queue, true); + return; + } PlayerType playerType = PlayerHolder.getInstance().getType(); if (!PlayerHolder.getInstance().isPlayerOpen()) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); @@ -347,6 +368,10 @@ public static void openSearchFragment(final FragmentManager fragmentManager, } public static void expandMainPlayer(final Context context) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalVideoDetailHost.expand(); + return; + } context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER) .setPackage(context.getPackageName())); } @@ -357,6 +382,10 @@ public static void sendPlayerStartedEvent(final Context context) { } public static void showMiniPlayer(final FragmentManager fragmentManager) { + if (ThemeHelper.shouldUseExperimentalNewUi(fragmentManager.getFragments().isEmpty() + ? App.getApp() : fragmentManager.getFragments().get(0).requireContext())) { + return; + } final VideoDetailFragment instance = VideoDetailFragment.getInstanceInCollapsedState(); defaultTransaction(fragmentManager) .replace(R.id.fragment_player_holder, instance) @@ -375,6 +404,10 @@ public static void openVideoDetailFragment(@NonNull final Context context, @NonNull final String title, @Nullable final PlayQueue playQueue, final boolean switchingPlayers) { + if (ThemeHelper.shouldUseExperimentalNewUi(context) && url != null) { + ExperimentalVideoDetailHost.open(serviceId, url); + return; + } final boolean autoPlay; @Nullable final PlayerService.PlayerType playerType = PlayerHolder.getInstance().getType(); diff --git a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt new file mode 100644 index 000000000..842e6f414 --- /dev/null +++ b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt @@ -0,0 +1,61 @@ +package project.pipepipe.app + +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.schabi.newpipe.player.playqueue.PlayQueue +import project.pipepipe.app.service.PlaybackService + +object ExperimentalPlaybackRouter { + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + @JvmStatic + fun play(context: Context, queue: PlayQueue, mode: PlaybackMode, shuffle: Boolean) { + context.startService(Intent(context, PlaybackService::class.java)) + scope.launch { + val controller = awaitController() ?: return@launch + val items = LegacyPlayQueueAdapter.convert(queue) + SharedContext.updatePlaybackMode(mode) + SharedContext.queueManager.setQueue(items, queue.index) + if (shuffle) { + SharedContext.queueManager.shuffle(items.getOrNull(queue.index)?.uuid) + } + controller.prepare() + controller.play() + } + } + + @JvmStatic + fun enqueue(context: Context, queue: PlayQueue, next: Boolean) { + context.startService(Intent(context, PlaybackService::class.java)) + scope.launch { + val controller = awaitController() ?: return@launch + val items = LegacyPlayQueueAdapter.convert(queue) + if (SharedContext.queueManager.getCurrentQueue().isEmpty()) { + SharedContext.queueManager.setQueue(items, queue.index) + controller.prepare() + return@launch + } + items.forEach(SharedContext.queueManager::addItem) + if (next) { + val currentIndex = controller.currentItemIndex.value + repeat(items.size) { + val from = SharedContext.queueManager.getCurrentQueue().lastIndex + SharedContext.queueManager.moveItem(from, currentIndex + 1 + it) + } + } + } + } + + 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/mediasource/ExtractorMediaSourceFactory.kt b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt index 5f373de01..83c2259fe 100644 --- a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt +++ b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt @@ -20,6 +20,7 @@ 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.VideoStream +import org.schabi.newpipe.util.ExtractorHelper import java.io.ByteArrayInputStream import java.nio.charset.StandardCharsets @@ -51,7 +52,11 @@ class ExtractorMediaSourceFactory( ) override fun createMediaSource(mediaItem: MediaItem): MediaSource { - val streamInfo = requireNotNull(StreamInfoRepository.get(mediaItem.mediaId)) + 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) } diff --git a/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt index 9c3f8baa8..3b396d0f7 100644 --- a/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt +++ b/app/src/main/java/project/pipepipe/app/ui/ExperimentalVideoDetailHost.kt @@ -14,7 +14,9 @@ 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 { @@ -68,4 +70,33 @@ object ExperimentalVideoDetailHost { 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 + } } From 7b3b53be360ecc71a81051741dc0c5b08a7f97c0 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:54:51 +0800 Subject: [PATCH 08/14] player: add reusable media3 popup player --- .../schabi/newpipe/util/NavigationHelper.java | 4 + .../app/ExperimentalPlaybackRouter.kt | 6 + .../pipepipe/app/popup/PopupPlayerManager.kt | 186 ++++++++++++++++++ .../pipepipe/app/service/PlaybackService.kt | 16 ++ 4 files changed, 212 insertions(+) create mode 100644 app/src/main/java/project/pipepipe/app/popup/PopupPlayerManager.kt diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 15ec76b63..e8a33e46a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -164,6 +164,10 @@ public static void playOnPopupPlayer(final Context context, PermissionHelper.showPopupEnablementToast(context); return; } + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.play(context, queue, PlaybackMode.POPUP, false); + return; + } Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt index 842e6f414..839fff64e 100644 --- a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt +++ b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt @@ -26,6 +26,12 @@ object ExperimentalPlaybackRouter { } controller.prepare() controller.play() + if (mode == PlaybackMode.POPUP) { + context.startService( + Intent(context, PlaybackService::class.java) + .setAction(PlaybackService.ACTION_SHOW_POPUP) + ) + } } } 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 index 758253eb8..b269d5d19 100644 --- a/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt +++ b/app/src/main/java/project/pipepipe/app/service/PlaybackService.kt @@ -11,11 +11,13 @@ 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() @@ -38,10 +40,18 @@ class PlaybackService : MediaLibraryService() { .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?) { @@ -55,10 +65,16 @@ class PlaybackService : MediaLibraryService() { 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" + } } From 5c7b400700082aa9d0a961d6cc9c21767ac2d9c5 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:57:10 +0800 Subject: [PATCH 09/14] player: stabilize experimental media3 routing --- .../newpipe/util/CarConnectionStateReceiver.java | 5 +++++ .../org/schabi/newpipe/util/MediaButtonReceiver.java | 5 ++++- .../external_communication/InternalUrlsHandler.java | 10 +++++++++- .../util/external_communication/TextLinkifier.java | 7 +++++++ .../project/pipepipe/app/ExperimentalPlaybackRouter.kt | 5 +++++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/util/CarConnectionStateReceiver.java b/app/src/main/java/org/schabi/newpipe/util/CarConnectionStateReceiver.java index 43df5f04e..4de44481d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CarConnectionStateReceiver.java +++ b/app/src/main/java/org/schabi/newpipe/util/CarConnectionStateReceiver.java @@ -6,6 +6,7 @@ import androidx.car.app.connection.CarConnection; import org.schabi.newpipe.player.PlayerBinderInterface; import org.schabi.newpipe.player.mediasession.PlayerServiceInterface; +import project.pipepipe.app.service.PlaybackService; public class CarConnectionStateReceiver extends BroadcastReceiver { @@ -39,6 +40,10 @@ public static void setCarConnectionState(boolean connected) { } private static void shutdownOldService(Context context) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + context.stopService(new Intent(context, PlaybackService.class)); + return; + } ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { diff --git a/app/src/main/java/org/schabi/newpipe/util/MediaButtonReceiver.java b/app/src/main/java/org/schabi/newpipe/util/MediaButtonReceiver.java index 279b8ba2d..54665041e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/MediaButtonReceiver.java +++ b/app/src/main/java/org/schabi/newpipe/util/MediaButtonReceiver.java @@ -9,6 +9,9 @@ public class MediaButtonReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + return; + } // We only care about the MEDIA_BUTTON intent. if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) { KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); @@ -27,4 +30,4 @@ public void onReceive(Context context, Intent intent) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java index 6bc82cd83..2b625c93f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java @@ -20,6 +20,9 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ThemeHelper; + +import project.pipepipe.app.ExperimentalPlaybackRouter; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -100,8 +103,13 @@ private static boolean handleTimestampUrl(final Context context, @NonNull final Pattern pattern, @NonNull final CompositeDisposable disposables) { if(url.contains("internal://timestamp/")) { + final int timestamp = Integer.parseInt(url.split("internal://timestamp/")[1]); + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.seekTo(timestamp * 1000L); + return true; + } Intent intent = new Intent(ACTION_SEEK_TO); - intent.putExtra("Timestamp", Integer.parseInt(url.split("internal://timestamp/")[1])); + intent.putExtra("Timestamp", timestamp); context.sendBroadcast(intent); return true; } diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java index 606f40155..6af959a42 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java @@ -18,6 +18,9 @@ import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ThemeHelper; + +import project.pipepipe.app.ExperimentalPlaybackRouter; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -193,6 +196,10 @@ private static void addClickListenersOnTimestamps(final Context context, new ClickableSpan() { @Override public void onClick(@NonNull final View view) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.seekTo(timestampMatchDTO.seconds() * 1000L); + return; + } Intent intent = new Intent(ACTION_SEEK_TO); intent.putExtra("Timestamp", timestampMatchDTO.seconds()); context.sendBroadcast(intent); diff --git a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt index 839fff64e..3c34277f2 100644 --- a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt +++ b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt @@ -57,6 +57,11 @@ object ExperimentalPlaybackRouter { } } + @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 } From dd8a62e946c2468983d40b7d64e8b62127311d75 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:58:50 +0800 Subject: [PATCH 10/14] player: keep experimental queue and mode in sync --- .../schabi/newpipe/util/NavigationHelper.java | 20 +++++++++++++++---- .../app/ExperimentalPlaybackRouter.kt | 6 +++--- .../app/platform/AndroidMediaController.kt | 10 +++++++++- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index e8a33e46a..f58256550 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -140,6 +140,12 @@ public static void playOnMainPlayer(final AppCompatActivity activity, @NonNull final PlayQueue playQueue) { final PlayQueueItem item = playQueue.getItem(); if (item != null) { + if (ThemeHelper.shouldUseExperimentalNewUi(activity)) { + ExperimentalPlaybackRouter.play( + activity, playQueue, PlaybackMode.VIDEO_AUDIO, false); + ExperimentalVideoDetailHost.open(item.getServiceId(), item.getUrl()); + return; + } openVideoDetailFragment(activity, activity.getSupportFragmentManager(), item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, false); @@ -151,6 +157,12 @@ public static void playOnMainPlayer(final Context context, final boolean switchingPlayers) { final PlayQueueItem item = playQueue.getItem(); if (item != null) { + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.play( + context, playQueue, PlaybackMode.VIDEO_AUDIO, false); + ExperimentalVideoDetailHost.open(item.getServiceId(), item.getUrl()); + return; + } openVideoDetail(context, item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, switchingPlayers); @@ -212,14 +224,14 @@ public static void playOnBackgroundPlayerShuffled(final Context context, public static void enqueueOnPlayer(final Context context, final PlayQueue queue, final PlayerType playerType) { - if (ThemeHelper.shouldUseExperimentalNewUi(context) && playerType != PlayerType.POPUP) { - ExperimentalPlaybackRouter.enqueue(context, queue, false); - return; - } if ((playerType == PlayerType.POPUP) && !PermissionHelper.isPopupEnabled(context)) { PermissionHelper.showPopupEnablementToast(context); return; } + if (ThemeHelper.shouldUseExperimentalNewUi(context)) { + ExperimentalPlaybackRouter.enqueue(context, queue, false); + return; + } Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); final Intent intent = getPlayerEnqueueIntent(context, DeviceUtils.getPlayerServiceClass(), queue); diff --git a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt index 3c34277f2..6d1e14cdc 100644 --- a/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt +++ b/app/src/main/java/project/pipepipe/app/ExperimentalPlaybackRouter.kt @@ -49,9 +49,9 @@ object ExperimentalPlaybackRouter { items.forEach(SharedContext.queueManager::addItem) if (next) { val currentIndex = controller.currentItemIndex.value - repeat(items.size) { - val from = SharedContext.queueManager.getCurrentQueue().lastIndex - SharedContext.queueManager.moveItem(from, currentIndex + 1 + it) + items.forEachIndexed { offset, item -> + val from = SharedContext.queueManager.getIndexOfItemUuid(item.uuid) + SharedContext.queueManager.moveItem(from, currentIndex + 1 + offset) } } } diff --git a/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt b/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt index 2c2eea397..ee52a7787 100644 --- a/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt +++ b/app/src/main/java/project/pipepipe/app/platform/AndroidMediaController.kt @@ -17,6 +17,7 @@ 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 @@ -191,7 +192,14 @@ class AndroidMediaController( } override fun stopService() = onStopService() - override fun syncQueueShuffle() = Unit + 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)) From 2d260e95f1b88a2da3b7330f06c7d8e42378049d Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:00:50 +0800 Subject: [PATCH 11/14] ui: add experimental video detail content --- .../screens/videodetail/VideoDetailScreen.kt | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) 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 index 7387a3e3a..381f383a3 100644 --- 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 @@ -26,6 +26,8 @@ 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 @@ -36,11 +38,18 @@ 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 @@ -52,6 +61,7 @@ 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( @@ -141,16 +151,61 @@ fun VideoDetailScreen(viewModel: VideoDetailViewModel) { } } } - 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) - ) + 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( From 76de720628886399d4df187bd2248cc5f6767c4b Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:02:23 +0800 Subject: [PATCH 12/14] player: adapt media3 sources for bilibili and niconico --- .../ExtractorMediaSourceFactory.kt | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt index 83c2259fe..717616460 100644 --- a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt +++ b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt @@ -1,5 +1,6 @@ package project.pipepipe.app.mediasource +import android.content.Context import android.net.Uri import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -15,6 +16,8 @@ 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.stream.AudioStream import org.schabi.newpipe.extractor.stream.DeliveryMethod import org.schabi.newpipe.extractor.stream.Stream @@ -22,20 +25,27 @@ import org.schabi.newpipe.extractor.stream.StreamInfo 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( - private val dataSourceFactory: DefaultDataSource.Factory + context: Context ) : MediaSource.Factory { - constructor(context: android.content.Context) : this( + 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, + context.applicationContext, DefaultHttpDataSource.Factory() + .setUserAgent(DownloaderImpl.USER_AGENT) + .setDefaultRequestProperties(headers) .setConnectTimeoutMs(30_000) .setReadTimeoutMs(30_000) ) - ) override fun setDrmSessionManagerProvider( drmSessionManagerProvider: DrmSessionManagerProvider @@ -75,9 +85,9 @@ class ExtractorMediaSourceFactory( val video = selectVideo(streamInfo) val audio = selectAudio(streamInfo) val sources = buildList { - video?.let { add(createStreamSource(mediaItem, it)) } + video?.let { add(createStreamSource(mediaItem, it, streamInfo)) } if (video == null || video.isVideoOnly()) { - audio?.let { add(createStreamSource(mediaItem, it)) } + audio?.let { add(createStreamSource(mediaItem, it, streamInfo)) } } } require(sources.isNotEmpty()) @@ -94,8 +104,25 @@ class ExtractorMediaSourceFactory( .filter { it.deliveryMethod != DeliveryMethod.TORRENT } .maxByOrNull { it.averageBitrate } - private fun createStreamSource(mediaItem: MediaItem, stream: Stream): MediaSource = - when (stream.deliveryMethod) { + private fun createStreamSource( + mediaItem: MediaItem, + stream: Stream, + streamInfo: StreamInfo + ): MediaSource { + 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) @@ -103,6 +130,7 @@ class ExtractorMediaSourceFactory( .createMediaSource(mediaItem.withUri(stream.content, MimeTypes.APPLICATION_M3U8)) else -> error("Unsupported delivery method: ${stream.deliveryMethod}") } + } private fun createDashSource(mediaItem: MediaItem, stream: Stream): MediaSource { val factory = DashMediaSource.Factory(dataSourceFactory) From bc7e64cc886c6a5e3bc3b4c2a457e7d46fa3f916 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:03:09 +0800 Subject: [PATCH 13/14] player: build youtube manifests for media3 --- .../ExtractorMediaSourceFactory.kt | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt index 717616460..442e9c45c 100644 --- a/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt +++ b/app/src/main/java/project/pipepipe/app/mediasource/ExtractorMediaSourceFactory.kt @@ -18,10 +18,14 @@ 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 @@ -109,6 +113,9 @@ class ExtractorMediaSourceFactory( 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( @@ -132,17 +139,77 @@ class ExtractorMediaSourceFactory( } } + 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)) } - val manifestUri = Uri.parse(stream.manifestUrl ?: "") + 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(stream.content.toByteArray(StandardCharsets.UTF_8)) + ByteArrayInputStream(manifestContent.toByteArray(StandardCharsets.UTF_8)) ) - return factory.createMediaSource(manifest, mediaItem.withUri(manifestUri, MimeTypes.APPLICATION_MPD)) + return DashMediaSource.Factory(dataSourceFactory) + .createMediaSource(manifest, mediaItem.withUri(manifestUri, MimeTypes.APPLICATION_MPD)) } private fun MediaItem.withUri(uri: String, mimeType: String?): MediaItem = From 0b3143a7348c276d4c808000709af90bc82d6114 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:11:55 +0800 Subject: [PATCH 14/14] build: remove experimental player unit tests --- app/build.gradle | 1 - .../project/pipepipe/app/QueueManagerTest.kt | 58 ------------------- .../app/mediasource/MediaItemFactoryTest.kt | 20 ------- 3 files changed, 79 deletions(-) delete mode 100644 app/src/test/java/project/pipepipe/app/QueueManagerTest.kt delete mode 100644 app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt diff --git a/app/build.gradle b/app/build.gradle index 73c28e21b..8c1ce4883 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -339,7 +339,6 @@ dependencies { // implementation 'com.arthenica:ffmpeg-kit-https:6.0-2.LTS' implementation project(':ffmpeg') implementation 'com.arthenica:smart-exception-java:0.2.1' - testImplementation 'junit:junit:4.13.2' } static String getGitWorkingBranch() { diff --git a/app/src/test/java/project/pipepipe/app/QueueManagerTest.kt b/app/src/test/java/project/pipepipe/app/QueueManagerTest.kt deleted file mode 100644 index ae1cf3b12..000000000 --- a/app/src/test/java/project/pipepipe/app/QueueManagerTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package project.pipepipe.app - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import project.pipepipe.app.platform.PlatformMediaItem - -class QueueManagerTest { - private fun item(id: String) = PlatformMediaItem( - mediaId = id, - title = id, - artist = null, - artworkUrl = null, - durationMs = null, - serviceId = null, - uuid = id - ) - - @Test - fun queueOperationsKeepExpectedOrder() { - val manager = QueueManager() - manager.setQueue(listOf(item("a"), item("b"), item("c")), notifyOnly = true) - manager.moveItem(2, 1) - manager.removeItemByUuid("a") - manager.addItem(item("d")) - - assertEquals(listOf("c", "b", "d"), manager.getCurrentQueue().map { it.uuid }) - } - - @Test - fun shuffleCanRestoreOriginalOrder() { - val manager = QueueManager() - manager.setQueue(listOf(item("a"), item("b"), item("c")), notifyOnly = true) - manager.shuffle("b") - - assertTrue(manager.isShuffled()) - assertEquals("b", manager.getCurrentQueue().first().uuid) - - manager.unshuffle() - - assertFalse(manager.isShuffled()) - assertEquals(listOf("a", "b", "c"), manager.getCurrentQueue().map { it.uuid }) - } - - @Test - fun extrasAreMerged() { - val manager = QueueManager() - manager.setQueue( - listOf(item("a").copy(extras = mapOf("first" to 1))), - notifyOnly = true - ) - - manager.updateItemExtras("a", mapOf("second" to 2)) - - assertEquals(mapOf("first" to 1, "second" to 2), manager.getCurrentQueue().single().extras) - } -} diff --git a/app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt b/app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt deleted file mode 100644 index 6d5ad8843..000000000 --- a/app/src/test/java/project/pipepipe/app/mediasource/MediaItemFactoryTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package project.pipepipe.app.mediasource - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Test -import org.schabi.newpipe.extractor.stream.StreamInfo - -class MediaItemFactoryTest { - @Test - fun streamInfoIsRegisteredAndConverted() { - val streamInfo = StreamInfo(1, "id", "https://example.com/video", "title") - - val item = MediaItemFactory.fromStreamInfo(streamInfo, "uuid") - - assertEquals(streamInfo.url, item.mediaId) - assertEquals(streamInfo.name, item.title) - assertEquals("uuid", item.uuid) - assertSame(streamInfo, StreamInfoRepository.get(streamInfo.url)) - } -}