1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
|
package org.terst.feedreader.ui
/**
* Recognising a YouTube placeholder link, and loading an embed that actually plays.
*
* Split out of ReaderWebView for the usual reason: this is string work with no WebView, no
* gestures and no Compose in it, so it can be exercised without a device -- which matters here
* because the bug it fixes is invisible until a video is tapped.
*
* **Why the embed needs a referrer.** The player dialog called
* `loadUrl("https://www.youtube-nocookie.com/embed/<id>")` and YouTube answered with "Video
* player configuration error -- Error 153", reported as "YouTube play is misconfigured". The
* embed refuses to play for a page it cannot attribute to a site. Measured on an Android user
* agent:
*
* | how the embed is loaded | result |
* |--------------------------------------------|-----------|
* | plain, no referrer | Error 153 |
* | with `&origin=` in the query | Error 153 |
* | inside an `<iframe>` on an ordinary page | plays |
* | with a `Referer` header | plays |
*
* The last is what this uses. The iframe works too and was tried first, but it meant hosting
* the embed in a `loadDataWithBaseURL` document -- a synthetic origin, with the video in a
* cross-origin subframe of it. Both are WebView constructs with a history of rendering video
* oddly, and neither is needed: one request header gets the same result with the embed as the
* WebView's own ordinary top-level document.
*/
/** Video id characters, per YouTube: 11 of these and nothing else. */
private val VIDEO_ID_CHARS = Regex("[^A-Za-z0-9_-]")
/** Hosts whose links are YouTube's. Matched whole, so `notyoutube.com` is not one of them. */
private val YOUTUBE_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be")
/** Thumbnail hosts. YouTube serves these from i.ytimg.com and numbered variants of it. */
private val THUMBNAIL_HOST = Regex("^i[0-9]*\\.ytimg\\.com$")
/** `/vi/<id>/...` or `/vi_webp/<id>/...` -- the id is the first segment after the vi folder. */
private val THUMBNAIL_PATH = Regex("^/vi(?:_webp)?/([A-Za-z0-9_-]{11})/")
/** `?v=<id>` anywhere in the query. */
private val WATCH_QUERY = Regex("[?&]v=([A-Za-z0-9_-]{11})(?:&|$)")
/** `/<id>` as the whole path, which is what youtu.be links are. */
private val SHORT_PATH = Regex("^/([A-Za-z0-9_-]{11})/?$")
/**
* The site the embed is told it is playing on.
*
* A constant, and a deliberately uninformative one: YouTube learns which app asked and nothing
* else. The article's own URL would satisfy the embed just as well and would tell YouTube what
* is being read, which is the thing choosing the nocookie domain already avoids. `.invalid` is
* reserved by RFC 2606, so this can never collide with somebody's real site.
*/
const val YOUTUBE_EMBED_REFERRER = "https://reader.feedreader.invalid/"
/**
* The video a long-press reported, as a watch URL, or null when the long-press was not on one.
*
* **What a long-press actually reports.** Not the link's href. The reader's video placeholder is
* `<a href="https://www.youtube.com/watch?v=ID"><img src="https://i.ytimg.com/vi/ID/hq720.jpg"></a>`,
* and for an image inside a link WebView's hit test gives SRC_IMAGE_ANCHOR_TYPE with the
* *image's* URL in `getExtra()`, not the anchor's. Confirmed on the device: long-pressing the
* thumbnail opened the ordinary link menu showing the i.ytimg.com JPEG. So the thumbnail URL is
* the shape that has to be recognised, and it carries the video id in its path.
*
* Watch and youtu.be links are recognised too, for wherever one does arrive -- a plain text link
* to a video in an article, which is not wrapped in an image.
*
* Anything on YouTube that is not one specific video -- a channel, a playlist -- returns null and
* gets the ordinary actions menu, since "open this directly" has no single video to mean.
*/
fun youTubeTargetForLongPress(url: String): String? {
val host = hostOf(url) ?: return null
val path = pathOf(url)
val id = when {
THUMBNAIL_HOST.matches(host) -> THUMBNAIL_PATH.find(path)?.groupValues?.get(1)
host !in YOUTUBE_HOSTS -> null
host.endsWith("youtu.be") -> SHORT_PATH.find(path)?.groupValues?.get(1)
else -> WATCH_QUERY.find(url)?.groupValues?.get(1)
}
return id?.let { youTubeWatchUrl(it) }
}
/** Where a video lives, in the form a phone hands to the YouTube app. */
fun youTubeWatchUrl(videoId: String): String = "https://www.youtube.com/watch?v=${scrub(videoId)}"
/**
* Where the player lives.
*
* `youtube-nocookie.com` rather than `youtube.com`: YouTube's own privacy-enhanced embed domain,
* so a casual view doesn't pull in the viewer's signed-in account and history. Must be loaded
* with [YOUTUBE_EMBED_REFERRER] as the `Referer` header or it refuses to play.
*/
fun youTubeEmbedUrl(videoId: String): String =
"https://www.youtube-nocookie.com/embed/${scrub(videoId)}?autoplay=1&playsinline=1&rel=0"
/**
* A script that gives YouTube's player a height, run once the embed has loaded.
*
* **Why a WebView needs this and a browser does not.** YouTube's embed lays itself out with
* `body { position: absolute; height: 100% }`, which resolves against the initial containing
* block -- and in this WebView that block has zero height. Measured in the running app:
* `window.innerHeight` correctly reports 214, while `100vh` and every percentage height resolve
* to 0. So the body collapsed, the player chain under it collapsed, and the video element was
* laid out 380x0.
*
* That is why the symptom was a *blank rectangle with working audio and working controls*: the
* page, the chrome and the stream were all fine, and the picture had nowhere to go. What showed
* through the zero-height player was YouTube's own white `<html>` background.
*
* The script pins the chain to the pixel height the page does know, and dispatches a resize so
* the player's own code re-measures. Confirmed on a device: the video element goes from 380x0 to
* 380x214 and the picture appears.
*
* This treats the symptom rather than whatever makes the viewport zero-height in the first
* place, which is below this app in the WebView itself. `!important` is deliberate -- YouTube's
* own styles would otherwise win it back on the next layout.
*/
const val YOUTUBE_FIT_PLAYER_JS: String =
"""(function () {
var h = window.innerHeight;
if (!h) { return; }
var css = document.createElement('style');
css.textContent =
'html,body{height:' + h + 'px !important;min-height:' + h + 'px !important}' +
'#player,#movie_player,.html5-video-player,.html5-video-container{height:' + h + 'px !important}' +
'video{height:' + h + 'px !important}';
document.head.appendChild(css);
window.dispatchEvent(new Event('resize'));
})();"""
/**
* The id with anything outside YouTube's own alphabet removed.
*
* The ids reaching here are already matched against `[a-zA-Z0-9_-]{11}` upstream, so this
* changes nothing today. It exists because the value is spliced straight into an HTML
* attribute, and that should be safe on its own terms rather than because of a check in a
* different file that a later edit might move.
*/
private fun scrub(videoId: String): String = VIDEO_ID_CHARS.replace(videoId, "")
/** The path of [url], or "/" when it has none. */
private fun pathOf(url: String): String {
val afterScheme = url.substringAfter("://", missingDelimiterValue = "")
val afterAuthority = afterScheme.substringAfter('/', missingDelimiterValue = "")
val path = "/" + afterAuthority.substringBefore('?').substringBefore('#')
return path
}
/** The host of [url], lowercased, or null when it is not a URL with one. */
private fun hostOf(url: String): String? {
val afterScheme = url.substringAfter("://", missingDelimiterValue = "")
if (afterScheme.isEmpty()) return null
val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#')
val host = authority.substringAfterLast('@').substringBefore(':')
return host.lowercase().ifEmpty { null }
}
|