在 EinkBro 中支援 Youtube 影片的雙語字幕

Daniel Kao
EinkBro
Published in
8 min readJun 30, 2023

這篇文章將講解怎麼在 EinkBro 中利用攔截 http request,將 Youtube 影片在呈現字幕時,能夠順便顯示第二種外語字幕。

這樣子的功能通常都是在 PC 瀏覽器上利用外掛的 extension 完成的。在手機或是平板上幾乎很少有瀏覽器可以支援這樣子的效果。之前我也一直很想要在平板上有類似這樣子的功能,原先是想要修改很好用的 Youtube Alternative App NewPipe,無奈它採用了 ExoPlayer 當播放器,而 ExoPlayer 對我來說又過於複雜,最終一直沒有試出來。

但是,回到 EinkBro App 上的話,要做類似的修改就容易多了。以下就來說說我是怎麼實作的。

找出字幕的 url request

利用 Google Chrome Debugger Tool 觀察 Youtube 顯示和不顯示字幕時,會多出一個 http request,如下圖。

它的全部 url 會類似下面這個例子

https://m.youtube.com/api/timedtext?v=-Y8ATrBvx3A…..

所以,目前的作法是只要看到有 timedtext 的 url,我就認定它應該是 Youtube要顯示字幕,需要開始來做點處理。

同時下載兩個語言的字幕檔

WebView 中想攔截 http resource request 的話,可以實作在 WebViewClient 中的 shouldInterceptRequest() 函式。

下面的程式碼寫得落落長,但重點只在於 newUrl = “$url&tlang=zh-Hant” 和下載 oldCaptionnewCaption。要從 timedtexturl 延伸成其他翻譯好的字幕語言,只需要加上 tlang 的參數就行了,是不是很方便。

下載後,再來就是把兩個字幕檔合併在一起,只要合併得好,其實Youtube 並不知道它究竟顯示的是它原先需要的原字幕檔,還是經過我處理的版本。合併的方式在下一小節說明。

private fun handleWebRequest(webView: WebView, uri: Uri): WebResourceResponse? {
...
if (url.contains("timedtext")) {
val newUrl = "$url&tlang=zh-Hant"
val oldCaption = runBlocking { BrowserUnit.getResourceFromUrl(url) }
val newCaption = runBlocking { BrowserUnit.getResourceFromUrl(newUrl) }
val oldCaptionJson = json.decodeFromString(TimedText.serializer(), String(oldCaption))
val newCaptionJson = json.decodeFromString(TimedText.serializer(), String(newCaption))

// 合併兩個字幕檔

return WebResourceResponse(
"application/json",
"UTF-8",
ByteArrayInputStream(
json.encodeToString(TimedText.serializer(), oldCaptionJson).toByteArray()
)
)
}
...
}

合併兩個字幕檔

仔細看 timedtext API 呼叫後回傳的資料結構(如下),它是一個 json 檔案,說明格式為 wireMagicpb3。而重點就在於其中的 eventsevents 中定義了每段字幕的起始/結束時間,和字幕內容。不論哪個語言都是以這種型式表現。

{
"wireMagic": "pb3",
"pens": [ {

} ],
"wsWinStyles": [ {

} ],
"wpWinPositions": [ {

} ],
"events": [ {
"tStartMs": 366,
"dDurationMs": 1834,
"segs": [ {
"utf8": "\t它画质只有2.7K"
} ]
}, {
"tStartMs": 2200,
"dDurationMs": 7633,
"segs": [ {
"utf8": "但是…我喜欢…今儿是新品影石insta360 Go3的主场"
} ]
}, {
...
}]
}

了解資料結構後,先建立一堆 data class,把 json 轉成可以操作的對象。

@Serializable
data class TimedText(
@SerialName("wireMagic") val wireMagic: String,
@SerialName("pens") val pens: List<Pen>,
@SerialName("wsWinStyles") val wsWinStyles: List<WsWinStyle>,
@SerialName("wpWinPositions") val wpWinPositions: List<WpWinPosition>,
@SerialName("events") val events: MutableList<Event>
)

@Serializable
class Pen

@Serializable
data class WsWinStyle(
@SerialName("mhModeHint") var mhModeHint: Int? = null,
@SerialName("juJustifCode") val juJustifCode: Int? = null,
@SerialName("sdScrollDir") var sdScrollDir: Int? = null
)

@Serializable
data class WpWinPosition(
@SerialName("apPoint") val apPoint: Int? = null,
@SerialName("ahHorPos") val ahHorPos: Int? = null,
@SerialName("avVerPos") val avVerPos: Int? = null,
@SerialName("rcRows") val rcRows: Int? = null,
@SerialName("ccCols") val ccCols: Int? = null
)

@Serializable
data class Event(
@SerialName("tStartMs") val tStartMs: Long = 0,
@SerialName("dDurationMs") val dDurationMs: Long = 0,
@SerialName("id") val id: Int = 0,
@SerialName("wpWinPosId") val wpWinPosId: Int? = null,
@SerialName("wsWinStyleId") val wsWinStyleId: Int? = null,
@SerialName("wWinId") val wWinId: Int? = 1,
@SerialName("segs") val segs: MutableList<Segment>? = mutableListOf()
)

@Serializable
data class Segment(
@SerialName("utf8") var utf8: String,
@SerialName("acAsrConf") val acAsrConf: Int = 0
)

下面是合併兩個檔案的方式:對於 startMs 相同的片段,就把它們的字幕組合到同一個區段中。

oldCaptionJson.wsWinStyles.forEach {
if (it.mhModeHint != null) {
it.mhModeHint = 0
}
if (it.sdScrollDir != null) {
it.sdScrollDir = 0
}
}
oldCaptionJson.events.forEach { event ->
if (event.segs != null && event.segs.size > 0) {
val first = event.segs.first()
first.utf8 = event.segs.map { it.utf8 }.reduce { acc, s -> acc + s }
first.utf8 += "\n" +
newCaptionJson.events.firstOrNull {
it.tStartMs == event.tStartMs }?.segs?.map { it.utf8 }
?.reduce { acc, str -> acc + str }
?: ""
event.segs.clear()
event.segs.add(first)
}
}

這麼一來,就完成啦!

示範影片

相關程式碼

commit: https://github.com/plateaukao/einkbro/commit/ab6f79c285e6d546dcfa1e8e5c6efb09e9abcd60

--

--

Daniel Kao
EinkBro

2023 年新書出版! Android 開源專案「真」實戰啟航:瀏覽器 App EinkBro 開發者帶你逐步從 UI 設計、UX 提升到多功能實現秘技全解析