Skip to content

Commit

Permalink
Bring back in-app filtering
Browse files Browse the repository at this point in the history
Largely brought back from 91c8465
  • Loading branch information
arkon committed May 18, 2024
1 parent 1761bd4 commit ae9ecd3
Show file tree
Hide file tree
Showing 44 changed files with 1,688 additions and 917 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
app/src/main/assets/*

.idea/
*.iml
.gradle
Expand All @@ -8,3 +6,4 @@ app/src/main/assets/*
/captures
.externalNativeBuild
local.properties
.kotlin/
3 changes: 0 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
[submodule "extension/LiveTL"]
path = extension/LiveTL
url = [email protected]:LiveTL/LiveTL.git
33 changes: 7 additions & 26 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -116,37 +116,14 @@ dependencies {
// OSS licenses
implementation(libs.aboutLibraries.compose)

// Tests
testImplementation(libs.bundles.test)

// For detecting memory leaks; see https://square.github.io/leakcanary/
// "debugImplementation"("com.squareup.leakcanary:leakcanary-android:2.2")
}

tasks {
// Requires the submodules to already be initialized, i.e.:
// git submodule update --init --recursive
val buildExtensionSource by register("buildExtensionSource") {
doLast {
exec {
workingDir = File("../extension/LiveTL")
setCommandLine("yarn", "install")
}
exec {
workingDir = File("../extension/LiveTL")
setCommandLine("yarn", "build", "android")
}
}
}

val copyExtensionArtifacts by register<Copy>("copyExtensionArtifacts") {
dependsOn(buildExtensionSource)
from("../extension/LiveTL/build")
into("./src/main/assets")
include("**/*")
}

project.afterEvaluate {
tasks.findByName("mergePlaystoreReleaseAssets")?.dependsOn(copyExtensionArtifacts)
}

withType<KotlinCompile> {
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
compilerOptions {
Expand All @@ -161,4 +138,8 @@ tasks {
)
}
}

withType<Test> {
useJUnitPlatform()
}
}
174 changes: 174 additions & 0 deletions app/src/main/assets/ChatInjector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
for (event_name of ['visibilitychange', 'webkitvisibilitychange', 'blur']) {
window.addEventListener(event_name, e => e.stopImmediatePropagation(), true);
}

window.fetchFallback = window.fetch;
window.fetch = async (...args) => {
const url = args[0].url;
const result = await window.fetchFallback(...args);

if (url.startsWith('https://www.youtube.com/youtubei/v1/live_chat/get_live_chat')) {
const response = await result.clone();
const json = await response.json();
try {
window.dispatchEvent(new CustomEvent('messageReceive', {
detail: json
}));
} catch (e) {
console.error('Failed to dispatch data', e);
}
}
return result;
}

// Process YouTube chat data
window.addEventListener('messageReceive', d => messageReceiveCallback(d.detail));

// Send processed data back to app
window.addEventListener('messagePostProcess', d => window.Android.receiveMessages(d.detail))

const isReplay = window.location.href.startsWith('https://www.youtube.com/live_chat_replay');

const getUsec = (timestamp, usec) => {
let secs = Array.from(timestamp.split(':'), t => parseInt(t, 10)).reverse();
secs = secs[0] + (secs[1] ? secs[1] * 60 : 0) + (secs[2] ? secs[2] * 60 * 60 : 0);
secs *= 1000;
secs += usec % 1000;
return secs;
};

const colorConversionTable = {
4280191205: 'BLUE',
4278248959: 'LIGHT_BLUE',
4280150454: 'TURQUOISE',
4294953512: 'YELLOW',
4294278144: 'ORANGE',
4293467747: 'PINK',
4293271831: 'RED'
};

const messageReceiveCallback = async (response) => {
try {
if (!response.continuationContents) {
console.warn('Response was invalid', JSON.stringify(response));
return;
}

const messages = [];
(response.continuationContents.liveChatContinuation.actions || []).forEach(action => {
try {
let currentElement = action.addChatItemAction;
if (action.replayChatItemAction != null) {
const thisAction = action.replayChatItemAction.actions[0];
currentElement = thisAction.addChatItemAction;
}
currentElement = (currentElement || {}).item;
if (!currentElement) {
return;
}

const messageItem = currentElement.liveChatTextMessageRenderer ||
currentElement.liveChatPaidMessageRenderer ||
currentElement.liveChatPaidStickerRenderer ||
currentElement.liveChatMembershipItemRenderer;
if (!messageItem) {
return;
}
if (!messageItem.authorName) {
console.debug('Missing authorName', JSON.stringify(currentElement));
return;
}

let isAuthorModerator = false;
let isAuthorVerified = false;
let isAuthorOwner = false;
let isNewMember = false;
let authorMembership = null;
(messageItem.authorBadges || []).forEach((badge) => {
const tooltip = badge.liveChatAuthorBadgeRenderer.tooltip;
if (tooltip === 'Moderator') {
isAuthorModerator = true;
}

if (tooltip === 'Verified') {
isAuthorVerified = true;
}

if (tooltip === 'Owner') {
isAuthorOwner = true;
}

if (tooltip === 'New member' && currentElement.liveChatMembershipItemRenderer) {
isNewMember = true;
}

if (tooltip.startsWith('Member') || tooltip === 'New member') {
const thumbnails = badge.liveChatAuthorBadgeRenderer.customThumbnail.thumbnails;
authorMembership = {
name: badge.liveChatAuthorBadgeRenderer.tooltip,
thumbnailSrc: thumbnails[thumbnails.length - 1].url
}
}
});

const runs = [];
if (messageItem.message) {
messageItem.message.runs.forEach((run) => {
if (run.text) {
runs.push({
type: 'text',
text: decodeURIComponent(escape(unescape(encodeURIComponent(
run.text
))))
});
} else if (run.emoji) {
const thumbnails = run.emoji.image.thumbnails;
runs.push({
type: 'emoji',
emojiId: run.emoji.shortcuts[0],
emojiSrc: thumbnails[thumbnails.length - 1].url
});
}
});
}

const timestampUsec = parseInt(messageItem.timestampUsec, 10);
const authorThumbnails = messageItem.authorPhoto.thumbnails;
const item = {
author: {
name: messageItem.authorName.simpleText,
id: messageItem.authorExternalChannelId,
photo: authorThumbnails[authorThumbnails.length - 1].url,
isModerator: isAuthorModerator,
isVerified: isAuthorVerified,
isOwner: isAuthorOwner,
isNewMember: isNewMember,
membershipBadge: authorMembership,
},
messages: runs,
timestamp: timestampUsec,
delay: isReplay
? getUsec(messageItem.timestampText.simpleText, timestampUsec)
: null
};

if (currentElement.liveChatPaidMessageRenderer) {
item.superchat = {
amount: messageItem.purchaseAmountText.simpleText,
color: colorConversionTable[messageItem.bodyBackgroundColor]
};
}

messages.push(item);
} catch (e) {
console.error('Error while parsing message.', e);
}
});

window.dispatchEvent(new CustomEvent('messagePostProcess', {
detail: JSON.stringify({ messages, isReplay })
}));
} catch (e) {
console.error(e);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.livetl.android.data.chat

import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

class ChatFilterService @Inject constructor(
private val chatService: ChatService,
private val chatFilterer: ChatFilterer,
) {

private var job: Job? = null

private val _messages = MutableStateFlow<ImmutableList<ChatMessage>>(persistentListOf())
val messages: SharedFlow<ImmutableList<ChatMessage>>
get() = _messages.asSharedFlow()

suspend fun connect(videoId: String, isLive: Boolean) {
// Clear out previous chat contents, just in case
stop()

chatService.connect(videoId, isLive)

job = chatService.scope.launch {
chatService.messages.collect {
_messages.value = it.mapNotNull(chatFilterer::filterMessage).toImmutableList()
}
}
}

fun seekTo(videoId: String, second: Long) {
chatService.seekTo(videoId, second)
}

fun stop() {
chatService.stop()
job?.cancel()
_messages.value = persistentListOf()
}
}
86 changes: 86 additions & 0 deletions app/src/main/kotlin/com/livetl/android/data/chat/ChatFilterer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.livetl.android.data.chat

import androidx.compose.ui.util.fastFirstOrNull
import com.livetl.android.util.AppPreferences
import javax.inject.Inject

class ChatFilterer @Inject constructor(private val prefs: AppPreferences) {
fun filterMessage(message: ChatMessage): ChatMessage? {
if (prefs.showAllMessages().get()) {
return message
}

if (
(prefs.showModMessages().get() && message.author.isModerator) ||
(prefs.showVerifiedMessages().get() && message.author.isVerified) ||
(prefs.showOwnerMessages().get() && message.author.isOwner)
) {
return parseMessage(message)?.second ?: message
}

parseMessage(message)?.let { (lang, parsedMessage) ->
if (prefs.tlLanguages().get().contains(lang.id)) {
return parsedMessage
}
}

return null
}

private fun parseMessage(message: ChatMessage): Pair<TranslatedLanguage, ChatMessage>? {
val firstTextContent = message.content.fastFirstOrNull { it is ChatMessageContent.Text }

// Language tag should be at the beginning as text
if (firstTextContent == null || firstTextContent !is ChatMessageContent.Text) {
return null
}

val trimmedText = firstTextContent.text.trim()
if (trimmedText.isEmpty()) {
return null
}

// We assume anything that roughly starts with something like "[EN]" is a translation
val leftToken = trimmedText[0]
val rightToken = LANG_TOKENS[leftToken]
val isTagged = rightToken != null && trimmedText.indexOf(rightToken) < MAX_LANG_TAG_LEN
if (!isTagged) {
return null
}

val (lang, text) = trimmedText.split(rightToken!!, limit = 2)
val taggedLang = TranslatedLanguage.fromId(lang.removePrefix(leftToken.toString()).trim())
val trimmedTextContent = ChatMessageContent.Text(
text.trim()
.removePrefix("-")
.removePrefix(":")
.trim(),
)

if (taggedLang == null) {
return null
}

return Pair(
taggedLang,
message.withContent(listOf(trimmedTextContent) + message.content.drop(1)),
)
}
}

private const val MAX_LANG_TAG_LEN = 7

private val LANG_TOKENS = mapOf(
'[' to ']',
'{' to '}',
'(' to ')',
'|' to '|',
'<' to '>',
'' to '',
'' to '',
'' to '',
'' to '',
'' to '',
'' to '',
'' to '',
)
Loading

0 comments on commit ae9ecd3

Please sign in to comment.