1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-02-03 16:50:17 +00:00

Compare commits

...

8 Commits

Author SHA1 Message Date
Profpatsch
6c3f31a721 WIP: integrate onBackPressed flow as callback
This sets up a little JavaFlow wrapper so we can register callbacks on
the back press flow in Java-land.

Inspired by one of the answers in
https://stackoverflow.com/questions/60605176/kotlin-flows-java-interop-callback

Kotlin generates default interface instances, but only if
`-Xjvm-default=all` is set in the compiler flags. The Java IDE
would propose using a lambda, which would fail because the kotlin
compiler would not generate the right ABI without that flag.
2024-12-25 18:33:49 +01:00
Profpatsch
10163e1082 WIP: try to integrate the newplayer a little better
* Should pause the player now if back button is hit.
* Video reloads if a different item is requested.
2024-12-25 18:33:49 +01:00
Profpatsch
0911d1ce7d WIP: initial repository setup for media.ccc streams
This uses the media.ccc.de URL as item-ID and the actual extractor to
fetch the streams.

Now we have a full top-to-bottom integration going, meaning we can
work on the stream selection based on actual data, not just made up
data.
2024-12-25 18:33:49 +01:00
Profpatsch
df3b56ed63 WIP: Correctly initialize NewPlayerViewModel
We need to pass the viewModel to the view after inflating our player
for the UI to render.
2024-12-25 18:33:49 +01:00
Profpatsch
cf351c28b0 WIP: Comment out old player
This just does an initial commenting pass to remove all references to
the old player from the video fragment, so that it won’t interfere
with FrankenPipe.
2024-12-25 18:33:49 +01:00
Profpatsch
4409a990de WIP: Play a simple media.ccc stream from the video fragment
This barely works, if you click on any video it should start playing a
media.ccc.de stream, but it does not display anything in the video
view yet.
2024-12-25 18:33:48 +01:00
Profpatsch
16b372dece TMP: temporary local gradle changes 2024-12-25 18:13:39 +01:00
Profpatsch
c02fb89359 WIP: Inject (a local) NewPlayer into the NewPipe application
This is the basic setup that allows us to inject a NewPlayer instance
into NewPipe. Frankenpipe, rise!
2024-12-25 18:12:55 +01:00
10 changed files with 1049 additions and 665 deletions

View File

@@ -42,7 +42,7 @@ android {
// suffix the app id and the app name with git branch name
def workingBranch = getGitWorkingBranch()
def normalizedWorkingBranch = workingBranch.replaceFirst("^[^A-Za-z]+", "").replaceAll("[^0-9A-Za-z]+", "")
def normalizedWorkingBranch = ""
if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") {
// default values when branch name could not be determined or is master or dev
applicationIdSuffix ".debug"
@@ -88,6 +88,11 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
freeCompilerArgs += [
// Generate default method implementations for interfaces
// https://kotlinlang.org/docs/java-to-kotlin-interop.html#default-methods-in-interfaces
'-Xjvm-default=all'
]
}
sourceSets {
@@ -168,7 +173,8 @@ afterEvaluate {
if (!System.properties.containsKey('skipFormatKtlint')) {
preDebugBuild.dependsOn formatKtlint
}
preDebugBuild.dependsOn runCheckstyle, runKtlint, checkDependenciesOrder
// preDebugBuild.dependsOn runCheckstyle,
preDebugBuild.dependsOn runKtlint, checkDependenciesOrder
}
sonar {
@@ -199,6 +205,8 @@ dependencies {
implementation libs.teamnewpipe.newpipe.extractor
implementation libs.teamnewpipe.nononsense.filepicker
implementation 'com.github.TeamNewPipe:NewPlayer'
/** Checkstyle **/
checkstyle libs.tools.checkstyle
ktlint libs.tools.ktlint
@@ -226,6 +234,9 @@ dependencies {
implementation libs.androidx.work.runtime
implementation libs.androidx.work.rxjava3
implementation libs.androidx.material
implementation libs.androidx.media3.common
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.ui
/** Third-party libraries **/
// Instance state boilerplate elimination

View File

@@ -94,6 +94,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
@AndroidEntryPoint
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@SuppressWarnings("ConstantConditions")

View File

@@ -0,0 +1,268 @@
/* NewPlayer
*
* @author Christian Schabesberger
*
* Copyright (C) NewPipe e.V. 2024 <code(at)newpipe-ev.de>
*
* NewPlayer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPlayer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPlayer. If not, see <http://www.gnu.org/licenses/>.
*/
package net.newpipe.newplayer.testapp
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.newpipe.newplayer.NewPlayer
import net.newpipe.newplayer.NewPlayerImpl
import net.newpipe.newplayer.data.AudioStreamTrack
import net.newpipe.newplayer.data.Chapter
import net.newpipe.newplayer.data.Stream
import net.newpipe.newplayer.data.Subtitle
import net.newpipe.newplayer.data.VideoStreamTrack
import net.newpipe.newplayer.repository.CachingRepository
import net.newpipe.newplayer.repository.MediaRepository
import net.newpipe.newplayer.repository.PrefetchingRepository
import okhttp3.OkHttpClient
import okhttp3.Request
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCStreamExtractor
import org.schabi.newpipe.extractor.services.media_ccc.extractors.data.MediaCCCRecording
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NewPlayerComponent {
@Provides
@Singleton
fun provideNewPlayer(app: Application): NewPlayer {
val player = NewPlayerImpl(
app = app,
repository = PrefetchingRepository(CachingRepository(MediaCCCTestRepository())),
notificationIcon = IconCompat.createWithResource(app, net.newpipe.newplayer.R.drawable.new_player_tiny_icon),
playerActivityClass = MainActivity::class.java,
// rescueStreamFault = …
)
if (app is App) {
CoroutineScope(Dispatchers.IO).launch {
while (true) {
player.errorFlow.collect { e ->
Log.e("NewPlayerException", e.stackTraceToString())
}
}
}
}
return player
}
}
class TestMediaRepository() : MediaRepository {
private val client = OkHttpClient()
override fun getRepoInfo() =
MediaRepository.RepoMetaInfo(canHandleTimestampedLinks = true, pullsDataFromNetwork = true)
@OptIn(UnstableApi::class)
override suspend fun getMetaInfo(item: String): MediaMetadata =
MediaMetadata.Builder()
.setTitle("BGP and the rule of bla")
.setArtist("mr BGP")
.setArtworkUri(Uri.parse("https://static.media.ccc.de/media/congress/2017/9072-hd.jpg"))
.setDurationMs(
1871L * 1000L
)
.build()
override suspend fun getStreams(item: String): List<Stream> {
return listOf(
Stream(
item = "bgp",
streamUri = Uri.parse("https://cdn.media.ccc.de/congress/2017/h264-hd/34c3-9072-eng-BGP_and_the_Rule_of_Custom.mp4"),
mimeType = null,
streamTracks = listOf(
AudioStreamTrack(
bitrate = 480000,
fileFormat = "MPEG4",
language = "en"
),
VideoStreamTrack(
width = 1920,
height = 1080,
frameRate = 25,
fileFormat = "MPEG4"
)
)
)
)
}
override suspend fun getSubtitles(item: String) =
emptyList<Subtitle>()
override suspend fun getPreviewThumbnail(item: String, timestampInMs: Long): Bitmap? {
val templateUrl = "https://static.media.ccc.de/media/congress/2017/9072-hd.jpg"
val thumbnailId = (timestampInMs / (10 * 1000)) + 1
if (getPreviewThumbnailsInfo(item).count < thumbnailId) {
return null
}
val thumbUrl = String.format(templateUrl, thumbnailId)
val bitmap = withContext(Dispatchers.IO) {
val request = Request.Builder().url(thumbUrl).build()
val response = client.newCall(request).execute()
try {
val responseBody = response.body
val bitmap = BitmapFactory.decodeStream(responseBody?.byteStream())
return@withContext bitmap
} catch (e: Exception) {
return@withContext null
}
}
return bitmap
}
override suspend fun getPreviewThumbnailsInfo(item: String) =
MediaRepository.PreviewThumbnailsInfo(0, 0)
override suspend fun getChapters(item: String) =
listOf<Chapter>()
override suspend fun getTimestampLink(item: String, timestampInSeconds: Long) =
""
}
class MediaCCCTestRepository() : MediaRepository {
private val client = OkHttpClient()
private val service = ServiceList.MediaCCC
suspend fun fetchPage(item: String): MediaCCCStreamExtractor {
return withContext(Dispatchers.IO) {
// TODO: handle MediaCCCLiveStreamExtractor as well
val extractor = service.getStreamExtractor(item) as MediaCCCStreamExtractor
extractor.fetchPage()
extractor
}
}
override fun getRepoInfo() =
MediaRepository.RepoMetaInfo(canHandleTimestampedLinks = true, pullsDataFromNetwork = true)
@OptIn(UnstableApi::class)
override suspend fun getMetaInfo(item: String): MediaMetadata {
val extractor = fetchPage(item)
return MediaMetadata.Builder().apply {
setTitle(extractor.name)
setArtist(extractor.subChannelName)
setDurationMs(
extractor.length * 1000L
)
extractor.thumbnails.firstOrNull()?.url?.let {
setArtworkUri(Uri.parse(it))
}
}.build()
}
override suspend fun getStreams(item: String): List<Stream> {
val extractor = fetchPage(item)
return extractor.recordings.filterIsInstance<MediaCCCRecording.Video>()
.filter { it.recordingType == MediaCCCRecording.VideoType.MAIN }
.map { track ->
Stream(
item = item,
streamUri = Uri.parse(track.url),
streamTracks =
listOf(
VideoStreamTrack(
width = track.width,
height = track.height,
fileFormat = track.mimeType
),
) +
// one audio track per language
// (TODO: probably dont need to attach the audio track here?)
track.languages.map { language ->
AudioStreamTrack(
// TODO: should we pass the Locale instead??
language = language.language,
fileFormat = track.mimeType,
// TODO: thats something ExoPlayer should determine for us,
// we dont know that from the metadata
bitrate = 44100,
)
}
)
}
}
override suspend fun getSubtitles(item: String) =
emptyList<Subtitle>()
override suspend fun getPreviewThumbnail(item: String, timestampInMs: Long): Bitmap? {
val extractor = fetchPage(item)
val templateUrl = extractor.thumbnails.firstOrNull()?.url ?: return null
val thumbnailId = (timestampInMs / (10 * 1000)) + 1
if (getPreviewThumbnailsInfo(item).count < thumbnailId) {
return null
}
val thumbUrl = String.format(templateUrl, thumbnailId)
val bitmap = withContext(Dispatchers.IO) {
val request = Request.Builder().url(thumbUrl).build()
val response = client.newCall(request).execute()
try {
val responseBody = response.body
val bitmap = BitmapFactory.decodeStream(responseBody?.byteStream())
return@withContext bitmap
} catch (e: Exception) {
return@withContext null
}
}
return bitmap
}
override suspend fun getPreviewThumbnailsInfo(item: String) =
MediaRepository.PreviewThumbnailsInfo(1, 0)
override suspend fun getChapters(item: String) =
listOf<Chapter>()
override suspend fun getTimestampLink(item: String, timestampInSeconds: Long) =
""
}

View File

@@ -0,0 +1,36 @@
package org.schabi.newpipe.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class JavaFlow<T>(
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) {
interface OperatorCallback <T> {
fun onStart() = Unit
fun onCompletion(thr: Throwable?) = Unit
fun onResult(result: T)
}
fun collect(
flow: Flow<T>,
operatorCallback: OperatorCallback<T>,
) {
coroutineScope.launch {
flow
.onStart { operatorCallback.onStart() }
.onCompletion { operatorCallback.onCompletion(it) }
.collect { operatorCallback.onResult(it) }
}
}
fun close() {
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<net.newpipe.newplayer.ui.NewPlayerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/embedded_player_newplayer"
android:name="net.newpipe.newplayer.VideoPlayerFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="50dp" />

View File

@@ -598,110 +598,110 @@
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<RelativeLayout
android:id="@+id/overlay_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0.9"
android:background="?attr/windowBackground">
<!-- <RelativeLayout-->
<!-- android:id="@+id/overlay_layout"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:alpha="0.9"-->
<!-- android:background="?attr/windowBackground">-->
<ImageButton
android:id="@+id/overlay_thumbnail"
android:layout_width="62dp"
android:layout_height="60dp"
android:layout_alignParentStart="true"
android:background="@color/transparent_background_color"
android:gravity="center_vertical"
android:paddingLeft="@dimen/video_item_search_padding"
android:paddingRight="@dimen/video_item_search_padding"
android:scaleType="fitCenter"
tools:ignore="ContentDescription" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_thumbnail"-->
<!-- android:layout_width="62dp"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_alignParentStart="true"-->
<!-- android:background="@color/transparent_background_color"-->
<!-- android:gravity="center_vertical"-->
<!-- android:paddingLeft="@dimen/video_item_search_padding"-->
<!-- android:paddingRight="@dimen/video_item_search_padding"-->
<!-- android:scaleType="fitCenter"-->
<!-- tools:ignore="ContentDescription" />-->
<LinearLayout
android:id="@+id/overlay_metadata_layout"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_toStartOf="@+id/overlay_buttons_layout"
android:layout_toEndOf="@+id/overlay_thumbnail"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="vertical"
android:theme="@style/ContrastTintTheme"
tools:ignore="RtlHardcoded">
<!-- <LinearLayout-->
<!-- android:id="@+id/overlay_metadata_layout"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_toStartOf="@+id/overlay_buttons_layout"-->
<!-- android:layout_toEndOf="@+id/overlay_thumbnail"-->
<!-- android:clickable="true"-->
<!-- android:focusable="true"-->
<!-- android:gravity="center_vertical"-->
<!-- android:orientation="vertical"-->
<!-- android:theme="@style/ContrastTintTheme"-->
<!-- tools:ignore="RtlHardcoded">-->
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/overlay_title_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_search_title_text_size"
tools:ignore="RtlHardcoded"
tools:text="The Video Title LONG very LONVideo Title LONG very LONG" />
<!-- <org.schabi.newpipe.views.NewPipeTextView-->
<!-- android:id="@+id/overlay_title_text_view"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:ellipsize="marquee"-->
<!-- android:fadingEdge="horizontal"-->
<!-- android:marqueeRepeatLimit="marquee_forever"-->
<!-- android:scrollHorizontally="true"-->
<!-- android:singleLine="true"-->
<!-- android:textAppearance="?android:attr/textAppearanceLarge"-->
<!-- android:textSize="@dimen/video_item_search_title_text_size"-->
<!-- tools:ignore="RtlHardcoded"-->
<!-- tools:text="The Video Title LONG very LONVideo Title LONG very LONG" />-->
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/overlay_channel_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
tools:text="The Video Artist LONG very LONG very Long" />
<!-- <org.schabi.newpipe.views.NewPipeTextView-->
<!-- android:id="@+id/overlay_channel_text_view"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:ellipsize="marquee"-->
<!-- android:fadingEdge="horizontal"-->
<!-- android:marqueeRepeatLimit="marquee_forever"-->
<!-- android:scrollHorizontally="true"-->
<!-- android:singleLine="true"-->
<!-- android:textAppearance="?android:attr/textAppearanceSmall"-->
<!-- android:textSize="@dimen/video_item_search_uploader_text_size"-->
<!-- tools:text="The Video Artist LONG very LONG very Long" />-->
</LinearLayout>
<!-- </LinearLayout>-->
<LinearLayout
android:id="@+id/overlay_buttons_layout"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_alignParentEnd="true"
android:gravity="center_vertical"
android:theme="@style/ContrastTintTheme"
tools:ignore="RtlHardcoded">
<!-- <LinearLayout-->
<!-- android:id="@+id/overlay_buttons_layout"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_alignParentEnd="true"-->
<!-- android:gravity="center_vertical"-->
<!-- android:theme="@style/ContrastTintTheme"-->
<!-- tools:ignore="RtlHardcoded">-->
<ImageButton
android:id="@+id/overlay_play_queue_button"
android:layout_width="40dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/title_activity_play_queue"
android:scaleType="center"
android:src="@drawable/ic_list"
tools:ignore="RtlHardcoded" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_play_queue_button"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/title_activity_play_queue"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_list"-->
<!-- tools:ignore="RtlHardcoded" />-->
<ImageButton
android:id="@+id/overlay_play_pause_button"
android:layout_width="40dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/pause"
android:focusable="true"
android:focusedByDefault="true"
android:scaleType="center"
android:src="@drawable/ic_play_arrow" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_play_pause_button"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/pause"-->
<!-- android:focusable="true"-->
<!-- android:focusedByDefault="true"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_play_arrow" />-->
<ImageButton
android:id="@+id/overlay_close_button"
android:layout_width="48dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/close"
android:paddingRight="8dp"
android:scaleType="center"
android:src="@drawable/ic_close"
tools:ignore="RtlSymmetry" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_close_button"-->
<!-- android:layout_width="48dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/close"-->
<!-- android:paddingRight="8dp"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_close"-->
<!-- tools:ignore="RtlSymmetry" />-->
</LinearLayout>
<!-- </LinearLayout>-->
</RelativeLayout>
<!-- </RelativeLayout>-->
</FrameLayout>

View File

@@ -17,6 +17,10 @@ buildscript {
}
}
plugins {
id 'com.google.dagger.hilt.android' version '2.52' apply false
}
allprojects {
repositories {
google()

View File

@@ -32,6 +32,7 @@ localbroadcastmanager = "1.1.0"
markwon = "4.6.2"
material = "1.11.0"
media = "1.7.0"
media3 = "1.3.1"
mockitoCore = "5.6.0"
navigationCompose = "2.8.3"
okhttp = "4.12.0"
@@ -96,6 +97,9 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }

View File

@@ -4,8 +4,15 @@ include ':app'
// We assume, that NewPipe and NewPipe Extractor have the same parent directory.
// If this is not the case, please change the path in includeBuild().
//includeBuild('../NewPipeExtractor') {
// dependencySubstitution {
// substitute module('com.github.TeamNewPipe:NewPipeExtractor') using project(':extractor')
// }
//}
includeBuild('../NewPipeExtractor') {
dependencySubstitution {
substitute module('com.github.TeamNewPipe:NewPipeExtractor') using project(':extractor')
}
}
includeBuild('../NewPlayer') {
dependencySubstitution {
substitute module('com.github.TeamNewPipe:NewPlayer') using project(':new-player')
}
}