Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4ba30be
Add ExoPlayer-based external-player module with intent filters, basic…
osphvdhwj Nov 1, 2025
0d87c02
Enhance external-player: PlayerService, MediaSession/notification, sp…
osphvdhwj Nov 1, 2025
9d6e9c0
Update AndroidManifest.xml with PlayerService and permissions
osphvdhwj Nov 1, 2025
3431c2d
Add PlayerService with ExoPlayer and MediaSession support
osphvdhwj Nov 1, 2025
df9d654
Add MediaNotificationManager for notification handling
osphvdhwj Nov 1, 2025
80ab513
Update ExternalPlayerActivity with service integration and UI controls
osphvdhwj Nov 1, 2025
8310569
Update activity layout with new UI controls
osphvdhwj Nov 1, 2025
455f84a
Update README with latest changes and features
osphvdhwj Nov 1, 2025
1533a61
Add GitHub Actions workflow for Android build and artifact upload
osphvdhwj Nov 1, 2025
b54b900
Update GitHub workflow to use actions/upload-artifact v4 to fix depre…
osphvdhwj Nov 1, 2025
0b0cdf2
Update workflow to build full debug variant and upload correct APK
osphvdhwj Nov 1, 2025
d2e79ba
Add hold screen gesture to set playback speed in ExternalPlayerActivity
osphvdhwj Nov 1, 2025
4a4d30f
Add hold-to-nX gesture in VideoPlayerUi: triggers current tempo value…
osphvdhwj Nov 1, 2025
dcd452e
Fix Checkstyle: add final to variables/params, wrap lines, add empty …
osphvdhwj Nov 1, 2025
fe1280b
Fix Checkstyle line length by wrapping long lines in VideoPlayerUi.java
osphvdhwj Nov 1, 2025
2c4ff6e
Fix Checkstyle line length errors in VideoPlayerUi.java setupHoldNXGe…
osphvdhwj Nov 1, 2025
3a2010d
Fix line length and update VideoPlayerUi.java after resolving conflict
osphvdhwj Nov 1, 2025
36a0d5e
Fix checkstyle violations: remove trailing spaces, shorten long lines…
osphvdhwj Nov 1, 2025
ec371ec
Fix Checkstyle violations: remove trailing spaces on line 5, split lo…
osphvdhwj Nov 1, 2025
dc9a58b
Add newline at end of file to fix Checkstyle violation
osphvdhwj Nov 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Android Build

on:
push:
branches:
- feature/mx-like-external-player

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
cache: gradle

- name: Download Android SDK command-line tools
uses: android-actions/setup-android@v2

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build Debug APK for full project
run: ./gradlew assembleDebug

- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: NewPipe-Debug-APK
path: app/build/outputs/apk/debug/app-debug.apk
25 changes: 5 additions & 20 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
.gradle/
local.properties
.DS_Store
build/
captures/
.idea/
*.iml
*~
.weblate
*.class
app/debug/
app/release/

# vscode / eclipse files
*.classpath
*.project
*.settings
bin/
.vscode/
*.code-workspace
# Excluded files
/external-player/build/
/*.iml
/.gradle
/local.properties
1,644 changes: 28 additions & 1,616 deletions app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions external-player/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# NewPipe External Player (feature/mx-like-external-player) — updated

What's in this update:
- Foreground PlayerService for background playback and persistent MediaStyle notification.
- MediaSession integration so lockscreen/playback controls work.
- Activity delegates playback to the service (better lifecycle behavior).
- Playbook speed control (cycle speeds), subtitle toggle placeholder.
- Updated manifest to request FOREGROUND_SERVICE permission.

Important integration notes:
- YouTube: ExoPlayer cannot play raw youtube.com/watch?v= links. Use NewPipe's extractor module to transform a YouTube page/ID into direct stream URLs (muxed/DASH) before passing URIs to PlayerService.
- Subtitles: This is currently a UI placeholder. To implement, supply VTT/SRT/TTML URLs from the extractor and attach the subtitle tracks to ExoPlayer in PlayerService.
- Audio focus & interruptions: Basic playback is implemented; extend audio focus handling and proper interruption handling for production.
- Casting, DRM, AD handling, advanced subtitle formats and timing are not in scope for this commit and should be added incrementally.

How to test locally:
1. Add `include ':external-player'` to your root settings.gradle
2. Build and install.
3. Start a direct HTTP(S) playable URL:
adb shell am start -a android.intent.action.VIEW -d "https://www.example.com/video.mp4" org.newpipe.externalplayer/.ExternalPlayerActivity
4. Observe playback continues when you press Home (notification appears). Use notification controls to pause/play.
5. Share a text containing a direct video URL via Android share sheet -> choose NewPipe External Player.
6. Try the speed button to cycle speeds, and the SUB button to toggle the placeholder.

Next steps:
- Integrate with the app's extractor to resolve YouTube links into playable stream URLs.
- Implement subtitles support in PlayerService and UI for subtitle selection.
- Implement proper audio focus handling and media-button receiver plumbing.
- Add instrumentation tests for service lifecycle and notification behavior.
51 changes: 51 additions & 0 deletions external-player/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {
compileSdkVersion 33

defaultConfig {
applicationId "org.newpipe.externalplayer"
minSdkVersion 21
targetSdkVersion 33
versionCode 1
versionName "1.1"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

buildFeatures {
viewBinding true
}

compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
}

dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'com.google.android.exoplayer:exoplayer:2.19.0'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.media:media-session:1.2.0'
implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation 'androidx.vectordrawable:vectordrawable:1.2.0'
implementation 'androidx.core:core:1.10.1'
implementation 'com.github.bumptech.glide:glide:4.15.1'
}
42 changes: 42 additions & 0 deletions external-player/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="org.newpipe.externalplayer"
xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<application
android:allowBackup="true"
android:label="NewPipe External Player"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true">

<activity
android:name=".ExternalPlayerActivity"
android:exported="true"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
</activity>

<!-- Foreground service that runs ExoPlayer for background playbook -->
<service
android:name=".PlayerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package org.newpipe.externalplayer

import android.app.PictureInPictureParams
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.GestureDetector
import android.view.MotionEvent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import org.newpipe.externalplayer.databinding.ActivityExternalPlayerBinding

class ExternalPlayerActivity : AppCompatActivity() {

private lateinit var binding: ActivityExternalPlayerBinding
private var serviceStarted = false
private val speeds = floatArrayOf(1.0f, 1.25f, 1.5f, 2.0f, 0.5f)
private var speedIndex = 0

private lateinit var gestureDetector: GestureDetector
private val holdSpeed = 2.0f // Speed to set on hold
private val normalSpeed = 1.0f // Normal playback speed

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityExternalPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)

handleIncomingIntent(intent)

binding.enterPipButton.setOnClickListener { enterPip() }
binding.speedButton.setOnClickListener {
speedIndex = (speedIndex + 1) % speeds.size
val speed = speeds[speedIndex]
binding.speedButton.text = "${speed}x"
val intent = Intent(this, PlayerService::class.java).apply {
action = PlayerService.ACTION_PLAY
putExtra("speed", speed)
}
ContextCompat.startForegroundService(this, intent)
}
binding.subToggle.setOnClickListener {
val newText = if (binding.subToggle.text == "SUB") "SUB:OFF" else "SUB"
binding.subToggle.text = newText
}

gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent?) {
setPlaybackSpeed(holdSpeed)
}
})

binding.playerView.setOnTouchListener { _, event ->
gestureDetector.onTouchEvent(event)

if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
setPlaybackSpeed(normalSpeed)
}

false
}
}

private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action
val data: Uri? = intent.data ?: intent.getParcelableExtra(Intent.EXTRA_STREAM)
if (Intent.ACTION_VIEW == action || data != null) {
data?.let { uri ->
binding.urlText.text = uri.toString()
startServiceWithUri(uri.toString())
}
} else if (Intent.ACTION_SEND == action) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.getStringExtra(Intent.EXTRA_STREAM)?.toString()
text?.let {
val url = extractUrlFromText(it)
if (url != null) startServiceWithUri(url)
}
}
}

private fun startServiceWithUri(uri: String) {
val intent = Intent(this, PlayerService::class.java).apply {
action = PlayerService.ACTION_SET_URI
putExtra(PlayerService.EXTRA_URI, uri)
}
ContextCompat.startForegroundService(this, intent)
serviceStarted = true
}

private fun extractUrlFromText(text: String): String? {
val regex = "(https?://[\\w\\-._~:/?#[\\]@!$&'()*+,;=%]+)".toRegex()
return regex.find(text)?.value
}

private fun enterPip() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ratio = Rational(binding.playerView.width.takeIf { it > 0 } ?: 16, binding.playerView.height.takeIf { it > 0 } ?: 9)
val params = PictureInPictureParams.Builder()
.setAspectRatio(ratio)
.build()
enterPictureInPictureMode(params)
}
}

override fun onUserLeaveHint() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
enterPip()
}
}

private fun setPlaybackSpeed(speed: Float) {
val intent = Intent(this, PlayerService::class.java).apply {
action = PlayerService.ACTION_PLAY
putExtra("speed", speed)
}
ContextCompat.startForegroundService(this, intent)
binding.speedButton.text = "${speed}x"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.newpipe.externalplayer

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle

class MediaNotificationManager(private val context: Context) {

fun buildNotification(player: com.google.android.exoplayer2.ExoPlayer?, token: android.os.Parcelable?): Notification {
val playIntent = Intent(context, PlayerService::class.java).apply { action = PlayerService.ACTION_PLAY }
val pauseIntent = Intent(context, PlayerService::class.java).apply { action = PlayerService.ACTION_PAUSE }
val stopIntent = Intent(context, PlayerService::class.java).apply { action = PlayerService.ACTION_STOP }

val playPending = PendingIntent.getService(context, 0, playIntent, PendingIntent.FLAG_IMMUTABLE)
val pausePending = PendingIntent.getService(context, 1, pauseIntent, PendingIntent.FLAG_IMMUTABLE)
val stopPending = PendingIntent.getService(context, 2, stopIntent, PendingIntent.FLAG_IMMUTABLE)

val isPlaying = player?.isPlaying == true
val action = if (isPlaying) {
NotificationCompat.Action(android.R.drawable.ic_media_pause, "Pause", pausePending)
} else {
NotificationCompat.Action(android.R.drawable.ic_media_play, "Play", playPending)
}

val mediaStyle = MediaStyle()
token?.let { mediaStyle.setMediaSession(it as android.media.session.MediaSession.Token) }

val builder = NotificationCompat.Builder(context, PlayerService.CHANNEL_ID)
.setContentTitle("NewPipe External Player")
.setContentText("Playing")
.setSmallIcon(android.R.drawable.ic_media_play)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, android.R.mipmap.sym_def_app_icon))
.addAction(action)
.addAction(NotificationCompat.Action(android.R.drawable.ic_media_previous, "Stop", stopPending))
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(isPlaying)

return builder.build()
}
}
Loading