Skip to content

Commit 4ba30be

Browse files
committed
Add ExoPlayer-based external-player module with intent filters, basic player UI and PiP support
1 parent 0a89276 commit 4ba30be

6 files changed

Lines changed: 274 additions & 20 deletions

File tree

.gitignore

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,5 @@
1-
.gradle/
2-
local.properties
3-
.DS_Store
4-
build/
5-
captures/
6-
.idea/
7-
*.iml
8-
*~
9-
.weblate
10-
*.class
11-
app/debug/
12-
app/release/
13-
14-
# vscode / eclipse files
15-
*.classpath
16-
*.project
17-
*.settings
18-
bin/
19-
.vscode/
20-
*.code-workspace
1+
# Excluded files
2+
/external-player/build/
3+
/*.iml
4+
/.gradle
5+
/local.properties

external-player/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# NewPipe External Player (feature/mx-like-external-player)
2+
3+
This module provides an ExoPlayer-based external video player which can be used as a system external player:
4+
- Accepts VIEW and SEND intents (http(s) links, text share).
5+
- Basic ExoPlayer integration with PlayerView and controls.
6+
- Picture-in-Picture (PiP) support (Android O+).
7+
- UI skeleton matching typical external players (MX-style quick controls).
8+
9+
What is included in this branch:
10+
- external-player module with PlayerActivity, layout, and manifest intent filters.
11+
- build.gradle configured for ExoPlayer.
12+
13+
Important notes / Limitations:
14+
- Direct YouTube links cannot be played directly by ExoPlayer; YouTube requires stream extraction. This branch includes placeholders and a TODO where the app should call the extractor already present in the main app (NewPipe's extractor) to produce playable stream URLs, or integrate a secure YouTube extraction pipeline.
15+
- DRM, subtitles, audio-only background service, casting, and advanced subtitle selection are not yet implemented in this initial commit.
16+
17+
Planned next steps / TODOs (can be split to issues):
18+
- Integrate with NewPipe extractor API to handle YouTube pages/IDs -> actual video stream URLs (muxed/dash) (High priority)
19+
- Support background playback and notification controls (media session & notification)
20+
- Support playlists and queueing (play next/previous)
21+
- Add subtitle downloading and selection (TTML/SRT/WebVTT)
22+
- Add audio boost, hardware acceleration toggles, speed control, equalizer integration
23+
- Implement Chromecast / DLNA / Google Cast support
24+
- Improve UX: gestures (seek/surface brightness/volume), aspect-ratio toggles, resume playback
25+
- Add tests and instrumentation tests for intent handling and PiP flows
26+
27+
How to test locally:
28+
1. Add `include ':external-player'` to your root settings.gradle
29+
2. Build and install the app variant or run as a standalone APK and send an ACTION_VIEW intent:
30+
adb shell am start -a android.intent.action.VIEW -d "https://www.example.com/video.mp4" org.newpipe.externalplayer/.ExternalPlayerActivity
31+
3. Share a YouTube URL via Android share sheet to the player to validate intent handling (extraction not implemented yet).
32+
33+
Security & privacy notes:
34+
- Do not embed broken/unsafe YouTube extractors. Prefer reusing the app's extractor or server-assisted extraction. Respect Terms of Service where applicable.
35+
- If integrating with YouTube, prefer the app's existing extractor infrastructure to avoid duplicating logic and leaking credentials.

external-player/build.gradle

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
apply plugin: 'com.android.application'
2+
3+
android {
4+
compileSdkVersion 33
5+
6+
defaultConfig {
7+
applicationId "org.newpipe.externalplayer"
8+
minSdkVersion 21
9+
targetSdkVersion 33
10+
versionCode 1
11+
versionName "1.0"
12+
}
13+
14+
buildTypes {
15+
release {
16+
minifyEnabled false
17+
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
18+
}
19+
}
20+
21+
buildFeatures {
22+
viewBinding true
23+
}
24+
}
25+
26+
dependencies {
27+
implementation 'androidx.appcompat:appcompat:1.6.1'
28+
implementation 'com.google.android.material:material:1.9.0'
29+
implementation 'androidx.core:core-ktx:1.10.1'
30+
implementation 'com.google.android.exoplayer:exoplayer:2.19.0'
31+
implementation 'androidx.media:media:1.6.0'
32+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest package="org.newpipe.externalplayer"
3+
xmlns:android="http://schemas.android.com/apk/res/android">
4+
5+
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.WAKE_LOCK" />
7+
8+
<application
9+
android:allowBackup="true"
10+
android:label="NewPipe External Player"
11+
android:icon="@mipmap/ic_launcher"
12+
android:roundIcon="@mipmap/ic_launcher_round"
13+
android:supportsRtl="true">
14+
15+
<activity
16+
android:name=".ExternalPlayerActivity"
17+
android:exported="true"
18+
android:resizeableActivity="true"
19+
android:supportsPictureInPicture="true">
20+
<intent-filter>
21+
<action android:name="android.intent.action.VIEW"/>
22+
<category android:name="android.intent.category.DEFAULT"/>
23+
<category android:name="android.intent.category.BROWSABLE"/>
24+
<data android:scheme="http"/>
25+
<data android:scheme="https"/>
26+
</intent-filter>
27+
28+
<intent-filter>
29+
<action android:name="android.intent.action.SEND" />
30+
<category android:name="android.intent.category.DEFAULT" />
31+
<data android:mimeType="text/*" />
32+
</intent-filter>
33+
</activity>
34+
35+
</application>
36+
</manifest>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package org.newpipe.externalplayer
2+
3+
import android.app.PictureInPictureParams
4+
import android.content.Intent
5+
import android.net.Uri
6+
import android.os.Build
7+
import android.os.Bundle
8+
import android.util.Rational
9+
import android.view.View
10+
import androidx.appcompat.app.AppCompatActivity
11+
import com.google.android.exoplayer2.MediaItem
12+
import com.google.android.exoplayer2.Player.STATE_READY
13+
import com.google.android.exoplayer2.SimpleExoPlayer
14+
import org.newpipe.externalplayer.databinding.ActivityExternalPlayerBinding
15+
16+
class ExternalPlayerActivity : AppCompatActivity() {
17+
18+
private lateinit var binding: ActivityExternalPlayerBinding
19+
private var player: SimpleExoPlayer? = null
20+
private var playWhenReady = true
21+
private var playbackPosition: Long = 0
22+
23+
override fun onCreate(savedInstanceState: Bundle?) {
24+
super.onCreate(savedInstanceState)
25+
binding = ActivityExternalPlayerBinding.inflate(layoutInflater)
26+
setContentView(binding.root)
27+
28+
handleIncomingIntent(intent)
29+
30+
binding.enterPipButton.setOnClickListener {
31+
enterPip()
32+
}
33+
}
34+
35+
private fun handleIncomingIntent(intent: Intent?) {
36+
if (intent == null) return
37+
val action = intent.action
38+
val data: Uri? = intent.data ?: intent.getParcelableExtra(Intent.EXTRA_STREAM)
39+
if (Intent.ACTION_VIEW == action || data != null) {
40+
data?.let { uri ->
41+
binding.urlText.text = uri.toString()
42+
initializePlayer(uri.toString())
43+
}
44+
} else if (Intent.ACTION_SEND == action) {
45+
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.getStringExtra(Intent.EXTRA_STREAM)?.toString()
46+
text?.let {
47+
val url = extractUrlFromText(it)
48+
if (url != null) initializePlayer(url)
49+
}
50+
}
51+
}
52+
53+
private fun extractUrlFromText(text: String): String? {
54+
val regex = "(https?://[\w\-._~:/?#[\]@!$&'()*+,;=%]+)".toRegex()
55+
return regex.find(text)?.value
56+
}
57+
58+
private fun initializePlayer(url: String) {
59+
if (player == null) {
60+
player = SimpleExoPlayer.Builder(this).build()
61+
binding.playerView.player = player
62+
}
63+
val mediaItem = MediaItem.fromUri(Uri.parse(url))
64+
player!!.setMediaItem(mediaItem)
65+
player!!.playWhenReady = playWhenReady
66+
player!!.seekTo(playbackPosition)
67+
player!!.prepare()
68+
player!!.addListener(object : com.google.android.exoplayer2.Player.Listener {
69+
override fun onPlaybackStateChanged(state: Int) {
70+
binding.loadingView.visibility = if (state == STATE_READY) View.GONE else View.VISIBLE
71+
}
72+
})
73+
}
74+
75+
private fun enterPip() {
76+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
77+
val ratio = Rational(binding.playerView.width.takeIf { it > 0 } ?: 16, binding.playerView.height.takeIf { it > 0 } ?: 9)
78+
val params = PictureInPictureParams.Builder()
79+
.setAspectRatio(ratio)
80+
.build()
81+
enterPictureInPictureMode(params)
82+
}
83+
}
84+
85+
override fun onUserLeaveHint() {
86+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
87+
enterPip()
88+
}
89+
}
90+
91+
override fun onPause() {
92+
super.onPause()
93+
player?.let {
94+
playbackPosition = it.currentPosition
95+
playWhenReady = it.playWhenReady
96+
it.playWhenReady = false
97+
}
98+
}
99+
100+
override fun onResume() {
101+
super.onResume()
102+
player?.playWhenReady = playWhenReady
103+
}
104+
105+
override fun onStop() {
106+
super.onStop()
107+
if (!isInPictureInPictureMode) {
108+
releasePlayer()
109+
}
110+
}
111+
112+
override fun onDestroy() {
113+
super.onDestroy()
114+
releasePlayer()
115+
}
116+
117+
private fun releasePlayer() {
118+
player?.let {
119+
playbackPosition = it.currentPosition
120+
playWhenReady = it.playWhenReady
121+
it.release()
122+
player = null
123+
}
124+
}
125+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<androidx.coordinatorlayout.widget.CoordinatorLayout
3+
xmlns:android="http://schemas.android.com/apk/res/android"
4+
xmlns:tools="http://schemas.android.com/tools"
5+
android:id="@+id/root"
6+
android:layout_width="match_parent"
7+
android:layout_height="match_parent"
8+
tools:context=".ExternalPlayerActivity">
9+
10+
<com.google.android.exoplayer2.ui.PlayerView
11+
android:id="@+id/player_view"
12+
android:layout_width="match_parent"
13+
android:layout_height="match_parent"
14+
android:use_controller="true"
15+
android:show_timeout="3000" />
16+
17+
<TextView
18+
android:id="@+id/urlText"
19+
android:layout_width="match_parent"
20+
android:layout_height="wrap_content"
21+
android:padding="8dp"
22+
android:background="#66000000"
23+
android:textColor="#ffffff"
24+
android:textSize="12sp"
25+
android:layout_gravity="top"/>
26+
27+
<ProgressBar
28+
android:id="@+id/loadingView"
29+
style="@android:style/Widget.DeviceDefault.Light.ProgressBar.Large"
30+
android:visibility="gone"
31+
android:layout_gravity="center" />
32+
33+
<com.google.android.material.floatingactionbutton.FloatingActionButton
34+
android:id="@+id/enterPipButton"
35+
android:layout_width="wrap_content"
36+
android:layout_height="wrap_content"
37+
android:src="@android:drawable/ic_media_pause"
38+
android:layout_margin="16dp"
39+
android:layout_gravity="bottom|end" />
40+
41+
</androidx.coordinatorlayout.widget.CoordinatorLayout>

0 commit comments

Comments
 (0)