Skip to content

Commit

Permalink
Add notification listener to get stream from YouTube app
Browse files Browse the repository at this point in the history
  • Loading branch information
arkon committed May 18, 2024
1 parent 25b9290 commit 1761bd4
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 18 deletions.
47 changes: 30 additions & 17 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,32 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />

<!-- For Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />

<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@mipmap/ic_banner"
android:allowBackup="true"
android:name=".App"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Splash"
android:networkSecurityConfig="@xml/network_security_config">
android:theme="@style/Theme.Splash">
<activity
android:name=".ui.MainActivity"
android:launchMode="singleTask"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:exported="true">
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
Expand All @@ -40,21 +43,31 @@

<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<!-- URLs handled by YouTube app -->
<data android:scheme="https"/>
<data android:host="youtu.be"/>
<data android:host="youtube.com"/>
<data android:host="m.youtube.com"/>
<data android:host="www.youtube.com"/>
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="www.youtube.com" />

<!-- LiveTL URI; e.g. livetl://translate/123456 -->
<data android:scheme="livetl"/>
<data android:host="translate"/>
<data android:scheme="livetl" />
<data android:host="translate" />
</intent-filter>
</activity>

<service
android:name=".data.media.YouTubeNotificationListenerService"
android:exported="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.livetl.android.data.media

import android.content.ComponentName
import android.media.MediaMetadata
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.content.getSystemService
import com.livetl.android.data.stream.StreamService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

@AndroidEntryPoint
class YouTubeNotificationListenerService : NotificationListenerService() {

@Inject
lateinit var streamService: StreamService

private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)

override fun onNotificationPosted(sbn: StatusBarNotification) {
super.onNotificationPosted(sbn)

if (sbn.packageName != YOUTUBE_PACKAGE_NAME) {
return
}

val mediaSessionManager = getSystemService<MediaSessionManager>() ?: return

val component = ComponentName(this, YouTubeNotificationListenerService::class.java)
val sessions = mediaSessionManager.getActiveSessions(component)
val youtubeSession = sessions.find { it.packageName == YOUTUBE_PACKAGE_NAME } ?: return

// The notification and media session don't expose the actual ID of the YouTube video, unfortunately
val videoTitle = youtubeSession.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: return
val videoChannelName = youtubeSession.metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: return

scope.launch {
Timber.d("Looking up stream for $videoTitle / $videoChannelName")
val streamInfo = streamService.findStreamInfo(videoTitle, videoChannelName) ?: return@launch

val position = youtubeSession.playbackState?.position // in ms; 0 is live
val state = when (youtubeSession.playbackState?.state) {
PlaybackState.STATE_PAUSED -> "PAUSED"
PlaybackState.STATE_PLAYING -> "PLAYING"
else -> null
}

Timber.i("Current YouTube video: ${streamInfo.videoId} / ${streamInfo.title} / $position / $state")
}
}

override fun onNotificationRemoved(sbn: StatusBarNotification) {
super.onNotificationRemoved(sbn)

if (sbn.packageName != YOUTUBE_PACKAGE_NAME) {
return
}

Timber.i("onNotificationRemoved")
}
}

private const val YOUTUBE_PACKAGE_NAME = "com.google.android.youtube"
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import com.livetl.android.data.feed.FeedService
import com.livetl.android.data.feed.Stream
import com.livetl.android.data.feed.StreamStatus
import javax.inject.Inject
import javax.inject.Singleton

/**
* An in-memory cache of stream info to avoid unnecessary network calls where possible.
*/
@Singleton
class StreamRepository @Inject constructor(
private val videoIdParser: VideoIdParser,
private val feedService: FeedService,
Expand All @@ -26,4 +28,16 @@ class StreamRepository @Inject constructor(
val id = videoIdParser.getVideoId(urlOrId)
return streams.getOrPut(id) { feedService.getVideoInfo(id) }
}

fun findStream(title: String, channelName: String): Stream? {
val streamsByChannel = streams.values.filter { it.channel.name == channelName }

// Titles might differ a bit between the notification and HoloDex, so we
// just return the single stream if possible
if (streamsByChannel.size == 1) {
return streamsByChannel[0]
}

return streamsByChannel.find { it.title == title }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class StreamService @Inject constructor(
}
}

suspend fun findStreamInfo(title: String, channelName: String): StreamInfo? =
streamRepository.findStream(title, channelName)?.let {
getStreamInfo(it.id)
}

private suspend fun getChatContinuation(videoId: String): String? {
val result =
client.get("https://www.youtube.com/watch?v=$videoId") {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.livetl.android.ui.screen.about

import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
Expand All @@ -24,19 +28,22 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.livetl.android.BuildConfig
import com.livetl.android.R
import com.livetl.android.data.media.YouTubeNotificationListenerService
import com.livetl.android.ui.common.LinkIcon
import com.livetl.android.ui.common.PreferenceGroupHeader
import com.livetl.android.ui.common.PreferenceRow

@Composable
fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navigateToWelcome: () -> Unit) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current

Scaffold(
Expand All @@ -60,7 +67,9 @@ fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navig
},
) { contentPadding ->
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp),
) {
Expand Down Expand Up @@ -123,6 +132,33 @@ fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navig
)
}

if (BuildConfig.DEBUG) {
item {
PreferenceRow(
title = "Grant notification listener permissions",
onClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).apply {
val componentName =
ComponentName(
context.packageName,
YouTubeNotificationListenerService::class.java.getName(),
)
putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
componentName.flattenToString(),
)
}
} else {
Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS")
}

context.startActivity(intent)
},
)
}
}

item {
Spacer(Modifier.navigationBarsPadding())
}
Expand Down

0 comments on commit 1761bd4

Please sign in to comment.