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