├── .gitignore
├── Circle-icons-radio-blue-48.png
├── Circle-icons-radio-red-48.png
├── Circle-icons-radio-red.svg
├── Circle-icons-radio.png
├── Circle-icons-radio.svg
├── LICENSE
├── README.md
├── _locales
├── en
│ └── messages.json
├── ja
│ └── messages.json
└── zh_CN
│ └── messages.json
├── background.js
├── manifest.jsonc.tmpl
├── modules
├── auth.js
├── constants.js
├── recording.js
├── rules.js
├── static.js
├── timeshift.js
└── util.js
├── package.py
├── pages
├── offscreen.html
├── offscreen.js
├── options.html
├── options.js
├── popup.html
└── popup.js
├── response
├── area-JP1.html
├── area-JP10.html
├── area-JP11.html
├── area-JP12.html
├── area-JP13.html
├── area-JP14.html
├── area-JP15.html
├── area-JP16.html
├── area-JP17.html
├── area-JP18.html
├── area-JP19.html
├── area-JP2.html
├── area-JP20.html
├── area-JP21.html
├── area-JP22.html
├── area-JP23.html
├── area-JP24.html
├── area-JP25.html
├── area-JP26.html
├── area-JP27.html
├── area-JP28.html
├── area-JP29.html
├── area-JP3.html
├── area-JP30.html
├── area-JP31.html
├── area-JP32.html
├── area-JP33.html
├── area-JP34.html
├── area-JP35.html
├── area-JP36.html
├── area-JP37.html
├── area-JP38.html
├── area-JP39.html
├── area-JP4.html
├── area-JP40.html
├── area-JP41.html
├── area-JP42.html
├── area-JP43.html
├── area-JP44.html
├── area-JP45.html
├── area-JP46.html
├── area-JP47.html
├── area-JP5.html
├── area-JP6.html
├── area-JP7.html
├── area-JP8.html
├── area-JP9.html
├── auth2-JP1.html
├── auth2-JP10.html
├── auth2-JP11.html
├── auth2-JP12.html
├── auth2-JP13.html
├── auth2-JP14.html
├── auth2-JP15.html
├── auth2-JP16.html
├── auth2-JP17.html
├── auth2-JP18.html
├── auth2-JP19.html
├── auth2-JP2.html
├── auth2-JP20.html
├── auth2-JP21.html
├── auth2-JP22.html
├── auth2-JP23.html
├── auth2-JP24.html
├── auth2-JP25.html
├── auth2-JP26.html
├── auth2-JP27.html
├── auth2-JP28.html
├── auth2-JP29.html
├── auth2-JP3.html
├── auth2-JP30.html
├── auth2-JP31.html
├── auth2-JP32.html
├── auth2-JP33.html
├── auth2-JP34.html
├── auth2-JP35.html
├── auth2-JP36.html
├── auth2-JP37.html
├── auth2-JP38.html
├── auth2-JP39.html
├── auth2-JP4.html
├── auth2-JP40.html
├── auth2-JP41.html
├── auth2-JP42.html
├── auth2-JP43.html
├── auth2-JP44.html
├── auth2-JP45.html
├── auth2-JP46.html
├── auth2-JP47.html
├── auth2-JP5.html
├── auth2-JP6.html
├── auth2-JP7.html
├── auth2-JP8.html
└── auth2-JP9.html
└── ui
├── common_start.js
├── inspect_start.js
├── mobile.css
├── mobile_start.js
├── recochoku_header_caution.css
├── share_redirect.js
├── share_redirect_inject.js
├── tver_playable_mobile.css
└── tver_playable_ua_inspect.js
/.gitignore:
--------------------------------------------------------------------------------
1 | manifest.json
2 | *.zip
3 |
--------------------------------------------------------------------------------
/Circle-icons-radio-blue-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio-blue-48.png
--------------------------------------------------------------------------------
/Circle-icons-radio-red-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio-red-48.png
--------------------------------------------------------------------------------
/Circle-icons-radio-red.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/Circle-icons-radio.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio.png
--------------------------------------------------------------------------------
/Circle-icons-radio.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Rajiko
2 | ====================
3 |
4 | Why I do this:
5 | -------------------
6 | An overseas fan of Kalafina wanted to listen to the radio program 'Kalafina倶楽部' which was ended a few days after this extension have been developed.
7 |
8 | To **celebrate the reunion of Kalafina** at the end of 2024, I finally made Rajiko to support Manifest V3.
9 |
10 | How to use:
11 | -------------------
12 | 1. Install it from [Chrome webstore](https://chrome.google.com/webstore/detail/rajiko/ejcfdikabeebbgbopoagpabbdokepnff) or [Firefox addons](https://addons.mozilla.org/firefox/addon/rajiko/). Firefox for Android is also supported.
13 | 2. Do nothing or change default area by clicking icon which only affects live area.
14 | 3. Recording live or download timeshift by clicking icon.
15 | 4. Click icon or click pause button to stop recording.
16 | 5. If you have a timefree30 plan (or double plan), you could download timefree30 timeshift after logged in. (Note: for Firefox private mode, you need to login in normal mode too)
17 |
18 |
19 | Sponsor me!
20 | -------------------
21 | [](https://ko-fi.com/jackyzy823)
22 |
23 | You could sponsor me via [Ko-fi](https://ko-fi.com/jackyzy823) (Also the link is available in the popup menu of Rajiko)! Thank you!
24 | Note: For japanese or japan resident, Please don't sponsor unless you've subscribed a Radiko's [premium plan](https://radiko.jp/premium)!
25 |
26 |
27 | Permission Details:
28 | -------------------
29 | 1. activeTab : to get tab's url to decide whether auto refresh or alert or which radio to record.
30 | 2. cookies : for force setting radiko.jp's current location.
31 | 3. storage : for storing your location configuration.
32 | 4. webRequest : modify request to pass the authentication.
33 | 5. webRequestBlocking : modify request to pass the authentication.
34 | 6. \*://\*.radiko.jp/\* : the only site we aimed at.
35 | 7. declartiveContent : [TODO] for showing icon only on radiko pages.But firefox does not support this api.When firefox supports this api,tab permission will not be required.
36 | 8. downloads : for downloading recored audio.
37 | 9. \*://\*.smartstream.ne.jp/\* : the site where audio stored.
38 | 10. unlimitedStorage : for recording radio.
39 | 11. declarativeNetRequest: modify request to pass the authentication.
40 | 12. \*://\*.nhk.jp/\* : (bonus) support nhk radio.
41 | 13. \*://\*.nhk.or.jp/\* : (bonus) support nhk radio.
42 | 14. \*://\*.tver.jp/\* : (deprecated) (bonus) support tver.
43 | 15. \*://edge.api.brightcove.com/\* : (deprecated) (bonus) tver streaming url.
44 | 16. scripting: for fixing tver not working on linux.
45 | 17. offscreen: (Chrome only)for creating auido blob url to download.
46 | 18. \*://\*.radiko-cf.com/\*": the site where audio stored.
47 | 19. webRequestBlocking: (Firefox only) modify request to pass the authentication.
48 | 20. webRequestFilterResponse: (Firefox only) modify response to pass the area checkk authentication.
49 | 21. \*://\*.recochoku.jp/\* : (bonus) support recochoku.jp.
50 |
51 | What's new:
52 | ----------
53 | + version 3.2025.10 - **ring your bell**
54 |
55 | Update for special radio station: MAJAL
56 |
57 | + version 3.2025.9 - **I have a dream**
58 |
59 | Fix live recording bug when getting from_time.
60 |
61 | + version 3.2025.8 - **sandpiper**
62 |
63 | Split nhkradio and tver options
64 |
65 | + version 3.2025.7 - **seventh heaven**
66 |
67 | Fix firefox option permission request
68 |
69 | Remove TVer geo-bypass support due to media endpoint changed (but reserve the fix for TVer under linux and TVer width under Android)
70 |
71 | + version 3.2025.6 - **serenato**
72 |
73 | Thanks to the help of a sponsor, implemented timeshift30 download. Note: You need login Radiko premium account with timeshift30 plan or double plan! For firefox private mode user: you need to login Radiko premium account in normal mode to download timeshift30. (This is a firefox defect.)
74 |
75 |
76 | + version 3.2025.5 - **ARIA**
77 |
78 | Improve mobile usability
79 |
80 | Support recochoku.jp
81 |
82 |
83 | + version 3.2025.3 - **sprinter**
84 |
85 | Implement MV3 on Firefox
86 |
87 | + version 3.2025.2 - **fairytale**
88 |
89 | Re-implement: areafree without switching area.
90 |
91 | Re-implement: download timeshift.
92 |
93 | Re-implement: recording.
94 |
95 | + version 3.2025.1 - **oblivious**
96 |
97 | Relauch with MV3 support
98 |
99 | Version name scheme: manifest-version.year.revision
100 |
101 | Codename scheme: a song from Kalafina
102 |
103 | Support NHK Radio, TVer
104 |
105 | + version 0.3.6.1
106 |
107 | update manifest key for Firefox for Android
108 |
109 | + version 0.3.6
110 |
111 | major improvement on Firefox for Android
112 |
113 | + version 0.3.5
114 |
115 | update css in popup to fix issue in chrome. Many Thanks to @fireattack.
116 |
117 | upadte new radio-area map
118 |
119 | sync radiko android version code
120 |
121 | + version 0.3.4
122 |
123 | fix matching when urls have query param
124 |
125 | + version 0.3.3
126 |
127 | fix bug on chrome by calling storage API before DOMContentLoaded
128 |
129 | + version 0.3.2
130 |
131 | fix area check bypass
132 |
133 | upadte new radio-area map
134 |
135 | + version 0.3.1
136 |
137 | fix m3u8 endpoints
138 |
139 | fix aac urls endpoints
140 |
141 | fix drag issue in some cases.
142 |
143 | + version 0.3.0
144 |
145 | improve timezone handle.
146 |
147 | upadte new radio-area map
148 |
149 | sync radiko android version code
150 |
151 | + version 0.2.9
152 |
153 | update css in popup to fix issue in chrome 94 (close #8). Many Thanks to @andykamezou for his css advise.
154 |
155 | hijack ajax to avoid #!out issue (replace old method)
156 |
157 | upadte new radio-area map
158 |
159 | sync radiko android version code
160 |
161 | + version 0.2.7.2
162 |
163 | since chrome do not count hash in history. revert v0.2.7.
164 |
165 | + version 0.2.7.1
166 |
167 | make page history correct via history.back
168 |
169 | + version 0.2.7
170 |
171 | bypass unclosable dialog
172 |
173 | + version 0.2.6
174 |
175 | make share page bypass geoblock (fix #1)
176 |
177 | sync radiko android version code to 7.3.7
178 |
179 | + version 0.2.5.8
180 |
181 | upadte new radio-area map for エフエム佐賀 エフエム徳島
182 |
183 | sync radiko android version code to 7.2.9
184 |
185 | + version 0.2.5.7
186 |
187 | upadte new radio-area map for エフエム秋田、Rhythm Station エフエム山形、FM岡山、エフエム山陰、エフエム宮崎
188 |
189 | change some radio's name
190 |
191 | + version 0.2.5.6
192 |
193 | upadte new radio-area map for HI-SIX(エフエム高知)
194 |
195 | sync radiko android version code to 7.2.0
196 |
197 | + version 0.2.5.5
198 |
199 | fix compatible problem for Chrome 72 -
200 |
201 | + version 0.2.5.4
202 |
203 | fix cors issue for Chrome 76 +
204 |
205 | sync radiko android version code to 7.1.1
206 |
207 | + version 0.2.5.3
208 |
209 | remove alert to avoid stuck in Chrome on Windows
210 |
211 | sync radiko android version code to 7.0.6
212 |
213 | + version 0.2.5.2
214 |
215 | make extension work under incognito mode
216 |
217 | sync radiko android version code to 6.4.4
218 |
219 | fix download blob file problem in new version firefox
220 |
221 | + version 0.2.5.1
222 |
223 | dirty fix for live recording issue caused by radiko using new `rpaa` api for stream. (May encounter unexpected problem.Issues are welcomed.)
224 |
225 | + version 0.2.5
226 |
227 | fix error caused by radiko new type api requestheader (X-Radiko-AreaId).
228 |
229 | solved a problem caused by CORS and Disk cache.
230 |
231 | + version 0.2.4.1
232 |
233 | upadte new radio-area map for FMFUKUI(FM福井)
234 |
235 | + version 0.2.4
236 |
237 | sync radiko android version code to 6.4.0
238 |
239 | resolve 5s problem in some mediaplayer. Now aac are concated without id3 metadata.
240 |
241 | + version 0.2.3
242 |
243 | fix time display in ballon when dragging in timefree, fix dragging in different timezone (Don't know if this fix works)
244 |
245 | update gps info from radiko android DEVELOPER_MODE
246 |
247 | + version 0.2.2
248 |
249 | fix timefree bypass logic.
250 |
251 | + version 0.2.1
252 |
253 | Now, you can use areafree(エリアフリー) and timefree(タイムフリー) as premium(プレミアム会員) freely without any operation.
254 |
255 | For switching to other area in timefree(タイムフリー) page, only click 地域変更 button in timefree(タイムフリー) page.
256 |
257 | The "3 hours a day" limitation of timefree(タイムフリー) has been unblocked.You can listen no matter how long now. And also you can download timefree(タイムフリー) program.
258 |
259 | "Choose Area" is only needed in displaying area in live(ライブ).
260 |
261 | If there's any bug or problem ,please try to disable and then enable or reinstall it.If this does not help , please tell me via review page or github issue.
262 |
263 | Update to the newest radio table [20180412].
264 |
265 | + version 0.1.4.1
266 |
267 | bug fix: fix cookie error caused by different storage.local key name.
268 |
269 | continously improve mobile ui
270 |
271 | improve extension icon ui when recording
272 |
273 |
274 | + version 0.1.4
275 |
276 | change to responsive ui in firefox android !
277 |
278 | fix gps info mistake
279 |
280 | adjust to correct japan timezone via moment-timezone
281 |
282 | + version 0.1.3
283 |
284 | experimentally support recording radio. [Caution: this would cause slowing down popup page and increasing cpu usage if recording too long. No more than 30 minutes is recommended.]
285 |
286 | + version 0.1.2
287 |
288 | fix bug in firefox
289 |
290 | + version 0.1.1
291 |
292 | support firefox for android
293 |
294 | + version 0.1
295 |
296 | initial version
297 |
298 | Suppport List:
299 | ------------------
300 | + firefox latest
301 |
302 | + chromium like browser latest
303 |
304 | + firefox for android latest
305 |
306 | + kiwi browser for android
307 |
308 | + Edge browser for android (UI modification seems not work)
309 |
310 | Known Issue:
311 | ---------------
312 | + The timeshift program can not be played after downloading without force refresh ,becuase of a problem of xhr access-control with disk cache see :https://lists.w3.org/Archives/Public/www-archive/2017Aug/0000.html (solved by add Access-Control-Allow-Origin in response via extension or see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin CORS and caching ->set Vary: Origin in response?)
313 | + Failed to download recorded on firefox nightly and firefox for android nightly
314 | + Drag issue with timezone in timefree mode. (1.Always play the start part Wherever you drag 2.Cannot drag over time after localtime now) .The second problem may be caused by radiko which only aims atjapan user not handling timezone problem . (solved)
315 | + Timefree only plays 5 seconds. (Don't know whether this is my issue or radiko's ,update :solved)
316 |
317 |
318 | Technical Details:
319 | ------------------
320 | 1. How it works?
321 |
322 | The authentication of pc(html5) version radkio validates user's location via ip address.
323 |
324 | However the android version of radkio validates user via geolocation provided by GPS(if possible),not via user's ip.
325 |
326 | So why don't we use the authentication method of android version in pc to bypass ip check?
327 |
328 | The authentication includes two step:
329 | 1. auth1
330 |
331 | request : platform_info , user_id
332 |
333 | response : a token to be valid, full_key_offset ,partial_key_length
334 |
335 | 2. auth2
336 |
337 | request: token ,platform_info ,user_id, a parital key generated by full key and offset , connection type (in android), gps location(in android)
338 |
339 | response: Your location (and your token is valid for only this location) / OUT
340 |
341 | In the pc version,the full key is simplely placed in the javascript code in `apps/js/playerCommon.js` :
342 |
343 | ```javascript
344 | player = new RadikoJSPlayer($audio[0], 'pc_html5', 'bcd151073c03b352e1ef2fd66c32209da9ca0afa' /*full key*/ ...
345 | ```
346 | However the android version's full key is protected by native dynamic librarys.Obviously the key is much longer than that in pc version.
347 |
348 | 2. But how do you generate the partialkey/how do you get fullkey?
349 |
350 | By reversing android dynamic library,You can get the fullkey from .data segment after bypassing the root check ,emulator check and lots of anti-debugging tricks and waiting for itself to repair the .data segment.
351 |
352 | ABOUT AAC
353 | ------------
354 | 1. HLS(HTTP Live Streaming) using Packed Audio see : https://tools.ietf.org/html/draft-pantos-http-live-streaming-23 which is ID3 tag + audio sample(AAC_ADTS,MP3,AC3)
355 | 2. About com.apple.streaming.transportStreamTimestamp ? Could i use this to sort?(yes) PTS -> (stamp2 - stamp1) / (90*1000.0) https://blog.csdn.net/qq_32430349/article/details/50218317
356 | 3. Drop all ID3 tag? see id3 in hls :https://helpx.adobe.com/adobe-media-server/dev/timed-metadata-hls-hds-streams.html
357 | 4. ID3 header -> size PRIV Frame header (PRIV size flag) -> identi end with \x00 64bit data (31bit 0 and 33bit data bigendian)
358 | frame header see http://id3.org/id3v2.4.0-structure priv see http://id3.org/id3v2.4.0-frames
359 |
360 | TODO
361 | ------------
362 | 0. Using ffmpeg.js (based on Emscripten:an LLVM-to-JavaScript compiler) concating ts segments to avoid 5s problem in mediaplayer.Note:size is about 13MB. (depercated : just drop id3 tags and simplely concat adts strem)
363 | 1. Fake request headers more similarly (such as remove cookies and set accept,user agent,and etc) to avoid detection (partially done)
364 | due to the limitation of extension , cannot captialize some header's key
365 | 2. Automatic switch location , no need for manually choice. (consider not supporting)
366 | 3. Add recording function? (find solution on firefox -> webRequest.filterResponseData() and localstorge/chrome.storage -> downloads.download URL.createObjectURL(BlobObject), chrome may use xhr to save data , double trafic?)
367 | the right way to download data uri
368 | https://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api/40279050
369 |
370 | how to merge? (src site use hls.js to play m3u8 and aac)
371 | seems that directly concat is enough
372 |
373 | 4. Force Firefox android load web page,not app download page.(done)
374 | 5. consider generate different extension in different browser
375 |
376 | https://stackoverflow.com/questions/45911251/what-is-the-best-way-to-create-a-cross-browser-gmail-extension
377 | https://www.smashingmagazine.com/2017/04/browser-extension-edge-chrome-firefox-opera-brave-vivaldi/
378 | 6. modify firefox android page to responsive page. (partially done)
379 | 7. break the time limitation of timeshift and be able to download timeshift content (done)
380 |
--------------------------------------------------------------------------------
/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_app_description": {
3 | "message": "Rajiko is a tool for unblocking geolocation restriction of radiko.jp! Enjoy the radio of Tokyo, Aomori, etc. from Osaka, Nagasaki, and even overseas!"
4 | },
5 | "confirm_button": {
6 | "message": "Confirm"
7 | },
8 | "popup_title": {
9 | "message": "Choose Area - Rajiko"
10 | },
11 | "record_button_to_start": {
12 | "message": "Record $RADIONAME$",
13 | "placeholders": {
14 | "radioname": {
15 | "content": "$1",
16 | "example": "bayfm78"
17 | }
18 | }
19 | },
20 | "record_button_to_stop": {
21 | "message": "Stop recording"
22 | },
23 | "record_button_to_prepare": {
24 | "message": "Prepare to record $RADIONAME$",
25 | "placeholders": {
26 | "radioname": {
27 | "content": "$1",
28 | "example": "bayfm78"
29 | }
30 | }
31 | },
32 | "timeshift_button": {
33 | "message": "Download Timeshift"
34 | },
35 | "firefox_tf30_incognito": {
36 | "message": "Due to the limitation of Firefox, you need to login Radiko member in normal mode as well to download timefree30 program under private mode."
37 | }
38 | }
--------------------------------------------------------------------------------
/_locales/ja/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_app_description": {
3 | "message": "Rajikoはradiko.jpのジオロケーション制限を解除するツールです! 大阪、長崎、そして海外から東京、青森などのラジオをお楽しみください!"
4 | },
5 | "confirm_button": {
6 | "message": "確認する"
7 | },
8 | "popup_title": {
9 | "message": "エリアを選択してください - Rajiko"
10 | },
11 | "record_button_to_start": {
12 | "message": "$RADIONAME$を録音する",
13 | "placeholders": {
14 | "radioname": {
15 | "content": "$1",
16 | "example": "bayfm78"
17 | }
18 | }
19 | },
20 | "record_button_to_stop": {
21 | "message": "録音を停止する"
22 | },
23 | "record_button_to_prepare": {
24 | "message": "$RADIONAME$ の録音を準備する",
25 | "placeholders": {
26 | "radioname": {
27 | "content": "$1",
28 | "example": "bayfm78"
29 | }
30 | }
31 | },
32 | "timeshift_button": {
33 | "message": "タイムシフト ダウンロード"
34 | },
35 | "firefox_tf30_incognito": {
36 | "message": "Firefox の制限により、プライベート モードでタイムフリー30をダウンロードするには、通常モードでもRadikoメンバーにログインする必要があります。"
37 | }
38 | }
--------------------------------------------------------------------------------
/_locales/zh_CN/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_app_description": {
3 | "message": "Rajiko是一个解除radiko.jp地区限制的工具。能从大阪、长崎,甚至海外听到东京、青森等地的电台。"
4 | },
5 | "confirm_button": {
6 | "message": "确认"
7 | },
8 | "popup_title": {
9 | "message": "选择地区 - Rajiko"
10 | },
11 | "record_button_to_start": {
12 | "message": "录音 $RADIONAME$",
13 | "placeholders": {
14 | "radioname": {
15 | "content": "$1",
16 | "example": "bayfm78"
17 | }
18 | }
19 | },
20 | "record_button_to_stop": {
21 | "message": "停止录音"
22 | },
23 | "record_button_to_prepare": {
24 | "message": "准备录音 $RADIONAME$ ",
25 | "placeholders": {
26 | "radioname": {
27 | "content": "$1",
28 | "example": "bayfm78"
29 | }
30 | }
31 | },
32 | "timeshift_button": {
33 | "message": "下载タイムシフト"
34 | },
35 | "firefox_tf30_incognito": {
36 | "message": "由于Firefox的限制,您需要在普通模式下也登录 Radiko 会员才能在隐私模式下下载 timeshift30。"
37 | }
38 | }
--------------------------------------------------------------------------------
/background.js:
--------------------------------------------------------------------------------
1 | import { APP_VERSION_MAP, APP_KEY_MAP, IGNORELIST } from "./modules/static.js";
2 | import { genRandomInfo, genGPS, initiatorFromExtension, isFirefox } from "./modules/util.js"
3 | import { downloadtimeShift } from "./modules/timeshift.js"
4 | import { retrieve_token } from "./modules/auth.js"
5 | import { updateRadioRules, setUpNHKRadio, setUpTVer, updateAreaRules, setUpMobileRadiko, setUpRecochokuUserAgent } from "./modules/rules.js";
6 | import { radioAreaId, areaMap, areaList, areaSuffixList } from "./modules/constants.js";
7 | import { stream_listener_builder } from "./modules/recording.js"
8 |
9 | // try {
10 | // // Even with declarativeNetRequestFeedback and extensions.dnr.feedback,
11 | // // onRuleMatchedDebug is still not work under Firefox.
12 | // chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(info => console.log(info));
13 | // } catch { }
14 |
15 |
16 | // Register listeners synchronously. so need to store device info in local
17 | chrome.runtime.onMessage.addListener(async function (msg, sender, respCallback) {
18 | if (msg["update-area"]) {
19 | let area_id = msg["update-area"];
20 |
21 | await chrome.storage.local.set({ selected_areaid: area_id });
22 | console.log(`Update area to ${area_id}`);
23 |
24 | let { device_info: info } = await chrome.storage.local.get("device_info");
25 | if (!info) {
26 | console.warn("this shouldn't happen");
27 | info = genRandomInfo();
28 | }
29 |
30 | if (!isFirefox()) {
31 | updateAreaRules(area_id, info);
32 | }
33 | } else if (msg["share-redirect"]) {
34 | let param = msg["share-redirect"];
35 | chrome.tabs.update(sender.tab.id, { "url": "https://radiko.jp/#!/ts/" + param.station + "/" + param.t });
36 | } else if (msg["update-nhkradio"]) {
37 | // This works for firefox too
38 | await setUpNHKRadio(msg["update-nhkradio"] == "yes");
39 | } else if (msg["update-recochoku"]) {
40 | await setUpRecochokuUserAgent(msg["update-recochoku"] == "yes")
41 | } else if (msg["update-tver"]) {
42 | await setUpTVer(msg["update-tver"] == "yes")
43 | } else if (msg["download-timeshift"]) {
44 | let { link: link, tf30: tf30 } = msg["download-timeshift"];
45 | console.log(`start donwload timeshift ${link}`);
46 |
47 | let { timeshift_list: list, selected_areaid: area_id } = await chrome.storage.local.get(["timeshift_list", "selected_areaid"]);
48 | if (!list) {
49 | list = [];
50 | }
51 | list.push(link);
52 | await chrome.storage.local.set({ "timeshift_list": list });
53 |
54 | chrome.action.setBadgeBackgroundColor?.({ color: "#e73c64" });
55 | chrome.action.setBadgeText?.({ text: list.length.toString() });
56 | downloadtimeShift(link, area_id, tf30);
57 | } else if (msg["start-recording"]) {
58 | let radioname = msg["start-recording"];
59 | console.log(`Start recording ${radioname}`);
60 | // store in session, for popup menu to check if has running recording and get current recording's radioname
61 | await chrome.storage.session.set({ "current_recording": radioname });
62 |
63 | // We need service worker to keepalive here.
64 | // Because user can prepare to record and after 30s (the service work became inactive), then click play button.
65 |
66 | // DO when aac request is completed
67 | //TODO Firefox listen on what? onCompleted or onBeforeSendHeaders
68 | chrome.webRequest.onCompleted.addListener(
69 | // create a listener function
70 | stream_listener_builder(radioname),
71 | {
72 | urls: [
73 | `*://*.smartstream.ne.jp/${radioname}/*.aac*`,
74 | "*://rpaa.smartstream.ne.jp/segments/*.aac*",
75 | `*://*.radiko-cf.com/segments/*/*/${radioname}/*.aac*`,
76 | `*://*.smartstream.ne.jp/segments/*/*/${radioname}/*.aac*`
77 | ]
78 | , tabId: msg["tabId"] //restrict to specific tabid
79 | }
80 | );
81 | chrome.action.setIcon?.({ path: 'Circle-icons-radio-red-48.png' });
82 | }
83 | });
84 |
85 | /**
86 | * User could be just visiting the page, not be intent on clicking the play button.
87 | * It's a bit aggreesive :( But i have no choice. Blame on MV3.
88 | *
89 | * On live page, if user pause the audio and stay on a page a long time (token expired), then play. this won't be triggered.
90 | * On timeshift page, it is the same.
91 | */
92 | if (!isFirefox()) {
93 | chrome.webRequest.onBeforeRequest.addListener(
94 | async req => {
95 | if (initiatorFromExtension(req)) { return; }
96 |
97 | let [radioname] = req.url.split("/").at(-1).split(".");
98 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get(["selected_areaid"]);
99 | console.log(`Hit ${req.url} radioname ${radioname} , area ${selected_areaid}`);
100 |
101 | if (radioAreaId[radioname].area.includes(selected_areaid)) {
102 | return;
103 | }
104 |
105 | let [token, area_id] = await retrieve_token(radioname, selected_areaid);
106 | // We update rules in `"*://*.radiko.jp/v3/station/stream/pc_html5/*"` listener.
107 | },
108 | {
109 | urls: [
110 | // This is used in live and timeshift
111 | // https://radiko.jp/v3/radioweb/bansen/station/RADIONAME.xml
112 | "*://*.radiko.jp/v3/radioweb/bansen/station/*.xml*",
113 |
114 | // Domain name is GCP_API_DOMAIN
115 | // https://api.radiko.jp/program/v3/weekly/RADIONAME.xml
116 | // This is usable, but we only use one to avoid fetch token twice since these reuqests are very closed.
117 | // "*://*.radiko.jp/program/v3/weekly/*.xml*"
118 |
119 | // Only in live page
120 | // "*://*.radiko.jp/v3/feed/pc/extra/*.xml*"
121 |
122 | // Not all radio have v4 API
123 | //https://api.radiko.jp/program/v4/date/DATE/station/RADIONAME.json
124 |
125 | // maybe cached?
126 | // https://radiko.jp/v2/static/station/logo/RADIONAME/224x100.png
127 |
128 | // DONT USE, TOO FREQUNCEY
129 | // https://radiko.jp/v3/feed/pc/cm/RADIONAME.xml?_=
130 | // Only in live page
131 | // https://api.radiko.jp/music/api/v1/noas/RADIONAME/latest?size=20
132 | // Only in timeshift page
133 | // https://api.radiko.jp/music/api/v1/noas/RADIONAME?
134 | ],
135 | }
136 | );
137 | }
138 |
139 |
140 | /**
141 | * The most accurate listener for the playing event.
142 | * However when it happens, it is too late to generate token if token doesn't exist and then update rules.
143 | * So we can catch up the second request to m3u8 link, but the first one is 403 with wrong token.
144 | * Blame on MV3
145 | */
146 | chrome.webRequest.onBeforeRequest.addListener(
147 | async req => {
148 | if (initiatorFromExtension(req)) { return; }
149 |
150 | let [radioname] = req.url.split("/").at(-1).split(".")
151 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get(["selected_areaid"]);
152 | console.log(`Hit ${req.url} radioname ${radioname} , area ${selected_areaid}`);
153 |
154 | if (radioAreaId[radioname].area.includes(selected_areaid)) {
155 | return;
156 | }
157 | // Too LATE
158 | let [token, area_id] = await retrieve_token(radioname, selected_areaid);
159 | if (!isFirefox()) {
160 | updateRadioRules(radioname, area_id, token);
161 | }
162 | },
163 | {
164 | // The request fetch playlist_create_url
165 | urls: ["*://*.radiko.jp/v3/station/stream/pc_html5/*"]
166 | },
167 | isFirefox() ? ["blocking"] : []
168 | );
169 |
170 | // Register listeners synchronously. so need to store device info in session
171 | chrome.webRequest.onHeadersReceived.addListener(
172 | async resp => {
173 | if (initiatorFromExtension(resp)) { return; }
174 |
175 | let token = "";
176 | let offset = 0;
177 | let length = 0;
178 | let set = 0;
179 |
180 | for (let i = 0; i < resp.responseHeaders.length; i++) {
181 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keyoffset") {
182 | offset = parseInt(resp.responseHeaders[i].value);
183 | set ^= 1;
184 | }
185 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keylength") {
186 | length = parseInt(resp.responseHeaders[i].value);
187 | set ^= 1 << 1;
188 | }
189 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-authtoken") {
190 | token = resp.responseHeaders[i].value;
191 | set ^= 1 << 2;
192 | }
193 | }
194 |
195 | if (set != 0b111) {
196 | console.error("no enough info from auth2 response.");
197 | return;
198 | }
199 |
200 | console.log(`onHeadersReceived of auth1: token ${token}, offset ${offset} length ${length}`);
201 | let { device_info: info } = await chrome.storage.local.get("device_info");
202 | if (!info) {
203 | // This should not happen and is not recoverable
204 | // If generate again, X-Radiko-App may not be same in auth1 and auth2 then auth will fail.
205 | // TODO If all aSmartPhone8 then ok to regenerate info?
206 | // TempFix: assert all device are aSmartPhone8
207 | info = genRandomInfo();
208 | await chrome.storage.local.set({ "device_info": info });
209 | console.error("no device_info in local storage");
210 | // return
211 | }
212 |
213 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid");
214 | if (!area_id) {
215 | // This should not happen and is not recoverable
216 | console.error("no area_id in local storage");
217 | return;
218 | }
219 |
220 |
221 | let partial = btoa(atob(APP_KEY_MAP[APP_VERSION_MAP[info.appversion]]).slice(offset, offset + length));
222 | // Allow cookie in header
223 | // However if in incognito:spanning, private window will load cookie from normal window (and you can't choose which cookie store to use)
224 | // But firefox doesn't support incognito:split
225 | // https://github.com/Tampermonkey/tampermonkey/issues/816
226 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1380812
227 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1670278
228 | // ** So firefox user should be warned **
229 | let resp2 = await fetch('https://radiko.jp/v2/api/auth2', {
230 | headers: {
231 | 'X-Radiko-App': APP_VERSION_MAP[info.appversion],
232 | 'X-Radiko-App-Version': info.appversion,
233 | 'X-Radiko-Device': info.device,
234 | 'X-Radiko-User': info.userid,
235 | 'X-Radiko-AuthToken': token,
236 | 'X-Radiko-Partialkey': partial,
237 | 'X-Radiko-Location': genGPS(area_id),
238 | 'X-Radiko-Connection': "wifi",
239 | // modifying UA does not work here. so we use session rules RULEID.AUTH_FETCH.
240 | 'User-Agent': info.useragent
241 | },
242 | });
243 |
244 | // Don't save the token from default_area_id to avoid race condition.
245 | if (resp2.status == 200) {
246 | let { auth_tokens: authTokens } = await chrome.storage.session.get({ "auth_tokens": {} });
247 | authTokens[area_id] = { token: token, requestTime: Date.now() };
248 | await chrome.storage.session.set({ "auth_tokens": authTokens });
249 | }
250 | },
251 | {
252 | urls: ["*://*.radiko.jp/v2/api/auth1*"]
253 | },
254 | // Is here blocking necessary?
255 | isFirefox() ? ["blocking", "responseHeaders"] : ["responseHeaders"]
256 | );
257 |
258 |
259 | async function initialize() {
260 | let {
261 | selected_areaid: area_id,
262 | bonus_feature: bonus,
263 | recochoku_ua: recochoku_ua,
264 | nhkradio_bypass: nhkradio_bypass,
265 | tver_fix: tver_fix
266 | } = await chrome.storage.local.get(["selected_areaid", "bonus_feature", "recochoku_ua", "nhkradio_bypass", "tver_fix"]);
267 | //if not selected_areaid use default value:JP13
268 | if (!area_id) { area_id = "JP13"; }
269 | if (!recochoku_ua) { recochoku_ua = false; }
270 | if (!nhkradio_bypass) { nhkradio_bypass = false; }
271 | if (!tver_fix) { tver_fix = false; }
272 | // TODO: remove after several (like 5?) releases.
273 | if (!bonus) { bonus = false; } else {
274 | // Migration old bonus_feature key to tver_fix and nhkradio_bypass
275 | tver_fix = true;
276 | nhkradio_bypass = true;
277 | }
278 |
279 | //clean previous unfinshed recording or downloading content if exists.
280 | await chrome.storage.local.clear();
281 | await chrome.storage.local.set({
282 | "selected_areaid": area_id,
283 | "recochoku_ua": recochoku_ua,
284 | "tver_fix": tver_fix,
285 | "nhkradio_bypass": nhkradio_bypass,
286 | });
287 |
288 | // Re-generate device info every initalization
289 | // Why use storage.local instead of storage.session for device info?
290 | // See issue: https://issues.chromium.org/issues/389232707
291 | let info = genRandomInfo();
292 | console.log("Using ", info, " for ", area_id);
293 | await chrome.storage.local.set({ "device_info": info });
294 |
295 |
296 | chrome.action.setBadgeText?.({ text: "" });
297 |
298 | if (!isFirefox()) {
299 | updateAreaRules(area_id, info);
300 | }
301 |
302 | // Wired bug under incognito:split , contentscript id duplication?
303 | try { await setUpNHKRadio(nhkradio_bypass); } catch (ex) { console.warn(ex) }
304 | try { await setUpTVer(tver_fix); } catch (ex) { console.warn(ex) }
305 | try { await setUpRecochokuUserAgent(recochoku_ua); } catch (ex) { console.warn(ex) }
306 | try { await setUpMobileRadiko(); } catch (ex) { console.warn(ex) }
307 | }
308 |
309 | chrome.runtime.onInstalled.addListener(async (data) => {
310 | await initialize();
311 | })
312 | //main stuff do only once on profile started
313 | chrome.runtime.onStartup.addListener(async () => {
314 | await initialize();
315 | });
316 |
317 | /**
318 | *
319 | * Firefox Listeners
320 | *
321 | */
322 | if (isFirefox()) {
323 | // Set request header in auth1
324 | chrome.webRequest.onBeforeSendHeaders.addListener(async req => {
325 | if (initiatorFromExtension(req)) {
326 | // TODO if from ext, then remove Accept-Language,Accept,User-Agent
327 | // like RULEID.AUTH_FETCH
328 | return;
329 | }
330 | let { device_info: info } = await chrome.storage.local.get("device_info");
331 | if (!info) {
332 | // This should not happen and is not recoverable
333 | // If generate again, X-Radiko-App may not be same in auth1 and auth2 then auth will fail.
334 | // TODO If all aSmartPhone8 then ok to regenerate info?
335 | // TempFix: assert all device are aSmartPhone8
336 | info = genRandomInfo();
337 | await chrome.storage.local.set({ "device_info": info });
338 | console.error("no device_info in local storage");
339 | // return
340 | }
341 |
342 | req.requestHeaders = req.requestHeaders.filter(function (x) {
343 | return !IGNORELIST.includes(x.name.toLowerCase());
344 | });
345 |
346 | req.requestHeaders.push({
347 | name: "X-Radiko-User",
348 | value: info.userid
349 | });
350 | req.requestHeaders.push({
351 | name: "X-Radiko-App-Version",
352 | value: info.appversion
353 | });
354 | req.requestHeaders.push({
355 | name: "X-Radiko-App",
356 | value: APP_VERSION_MAP[info.appversion],
357 | });
358 | req.requestHeaders.push({
359 | name: "X-Radiko-Device",
360 | value: info.device
361 | });
362 | req.requestHeaders.push({
363 | name: "User-Agent",
364 | value: info.useragent
365 | });
366 | return {
367 | requestHeaders: req.requestHeaders
368 | };
369 | }, {
370 | urls: ["*://*.radiko.jp/v2/api/auth1*"]
371 | }, ["blocking", "requestHeaders"]);
372 |
373 | // Fix response header in auth1
374 | chrome.webRequest.onHeadersReceived.addListener(async resp => {
375 | if (initiatorFromExtension(resp)) { return; }
376 | for (let i = 0; i < resp.responseHeaders.length; i++) {
377 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keyoffset") {
378 | resp.responseHeaders[i].value = "0"; //to avoid too big offset cause radiko's js error
379 | }
380 | }
381 | return {
382 | responseHeaders: resp.responseHeaders
383 | };
384 | }, {
385 | urls: ["*://*.radiko.jp/v2/api/auth1*"]
386 | }, ["blocking", "responseHeaders"]);
387 |
388 | // Set area response content
389 | chrome.webRequest.onBeforeRequest.addListener(async req => {
390 | let filter = browser.webRequest.filterResponseData(req.requestId);
391 |
392 | filter.onstop = async event => {
393 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid");
394 | // Template document.write('OSAKA JAPAN');
395 | let encoder = new TextEncoder();
396 | filter.write(encoder.encode(`document.write('${areaMap[area_id]} JAPAN');`));
397 | filter.close();
398 | };
399 |
400 | return {};
401 | }, {
402 | // webRequest support "*://*.radiko.jp/area*" to match "https://radiko.jp/area"
403 | // But declarativeNetRequest don't, it only support "*://radiko.jp/area*"
404 | urls: ["*://*.radiko.jp/area*", "*://*.radiko.jp/apparea/area*"]
405 | }, ["blocking"]);
406 |
407 | // Set browser's auth2 response content
408 | chrome.webRequest.onBeforeRequest.addListener(async req => {
409 | if (initiatorFromExtension(req)) {
410 | // TODO add permission: requestHeaders
411 | // TODO if from ext, then remove Accept-Language,Accept,User-Agent
412 | // like RULEID.AUTH_FETCH
413 | return;
414 | }
415 | let filter = browser.webRequest.filterResponseData(req.requestId);
416 |
417 | filter.onstop = async event => {
418 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid");
419 | let encoder = new TextEncoder();
420 | let area_id_num = parseInt(area_id.substr(2)) - 1;
421 | filter.write(encoder.encode(`${area_id},${areaList[area_id_num]}${areaSuffixList[area_id_num]},${areaMap[area_id].toLowerCase()} Japan`));
422 | filter.close();
423 | };
424 |
425 | return {};
426 | }, {
427 | urls: ["*://radiko.jp/v2/api/auth2*"]
428 | }, ["blocking"]);
429 |
430 | // THE correct way to set authtoken to m3u8
431 | chrome.webRequest.onBeforeSendHeaders.addListener(async req => {
432 | if (initiatorFromExtension(req)) { return; }
433 | let radioname = (new URL(req.url)).searchParams.get("station_id");
434 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get("selected_areaid");
435 | if (radioAreaId[radioname].area.includes(selected_areaid)) {
436 | return {};
437 | }
438 |
439 | // Good timing! Firefox!
440 | let [token, area_id] = await retrieve_token(radioname, selected_areaid);
441 |
442 | req.requestHeaders = req.requestHeaders.filter(function (x) {
443 | return !["x-radiko-authtoken", "x-radiko-areaid"].includes(x.name.toLowerCase()); //remove previous token
444 | });
445 | req.requestHeaders.push({
446 | name: "X-Radiko-AuthToken",
447 | value: token
448 | });
449 | req.requestHeaders.push({
450 | name: "X-Radiko-AreaId",
451 | value: area_id
452 | });
453 |
454 | return {
455 | requestHeaders: req.requestHeaders
456 | };
457 |
458 | }, {
459 | urls: ["*://radiko.jp/v2/api/ts/playlist.m3u8?*" /*for timeshift*/
460 | , "*://*.smartstream.ne.jp/*/playlist.m3u8?*" // some new apis
461 | , "*://*.radiko-cf.com/*/playlist.m3u8?*"
462 | ]
463 | }, ["blocking", "requestHeaders"]);
464 | }
--------------------------------------------------------------------------------
/manifest.jsonc.tmpl:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Rajiko",
4 | "version": "3.2025.10",
5 | "description": "__MSG_manifest_app_description__",
6 | "icons": {
7 | "48": "Circle-icons-radio-blue-48.png",
8 | "128": "Circle-icons-radio.png"
9 | },
10 | "permissions": [
11 | "activeTab",
12 | "cookies",
13 | "storage",
14 | "webRequest",
15 | "downloads",
16 | "unlimitedStorage",
17 | "declarativeNetRequest",
18 | "scripting",
19 | // manifest.json allows comments
20 | // https://discourse.mozilla.org/t/manifest-json-should-mention-it-allows-comments/27529/3
21 | // Chrome only
22 | "offscreen",
23 | // Firefox only
24 | "webRequestBlocking",
25 | "webRequestFilterResponse"
26 | ],
27 | "host_permissions": [
28 | "*://*.radiko.jp/*",
29 | "*://*.smartstream.ne.jp/*",
30 | "*://*.radiko-cf.com/*"
31 | ],
32 | // Edge on Android don't support optional_host_permissions as of 2025/01/24
33 | "optional_host_permissions": [
34 | "*://*.nhk.jp/*",
35 | "*://*.nhk.or.jp/*",
36 | "*://*.tver.jp/*",
37 | "*://*.recochoku.jp/*"
38 | ],
39 | "default_locale": "ja",
40 | "incognito": "split",
41 | "background": {
42 | // chrome
43 | "service_worker": "background.js",
44 | // firefox
45 | "scripts": [
46 | "background.js"
47 | ],
48 | "type": "module"
49 | },
50 | "content_scripts": [
51 | {
52 | "matches": [
53 | "*://*.radiko.jp/"
54 | ],
55 | "js": [
56 | "ui/common_start.js"
57 | ],
58 | "run_at": "document_start"
59 | },
60 | {
61 | "matches": [
62 | "*://*.radiko.jp/share/?*noreload=1*"
63 | ],
64 | "js": [
65 | "ui/share_redirect.js"
66 | ],
67 | "run_at": "document_idle"
68 | }
69 | ],
70 | "web_accessible_resources": [
71 | {
72 | "resources": [
73 | "ui/inspect_start.js",
74 | "ui/share_redirect_inject.js"
75 | ],
76 | "matches": [
77 | "*://*.radiko.jp/*"
78 | ]
79 | },
80 | // Only for Chrome
81 | {
82 | "resources": [
83 | "response/*.html"
84 | ],
85 | "matches": [
86 | "*://*.radiko.jp/*"
87 | ]
88 | }
89 | ],
90 | "action": {
91 | "default_icon": "Circle-icons-radio-blue-48.png",
92 | "default_popup": "pages/popup.html",
93 | "default_title": "__MSG_popup_title__"
94 | },
95 | "options_ui": {
96 | "page": "pages/options.html",
97 | "open_in_tab": false
98 | },
99 | "browser_specific_settings": {
100 | "gecko": {
101 | "id": "{3a8c3e6f-40c7-4eeb-9e42-8d7a803af62b}",
102 | // Firefox is adding support for manifest version 3 (MV3) extensions in Firefox 109.0.
103 | // optional_host_permissions requires 128.0
104 | "strict_min_version": "128.0"
105 | },
106 | "gecko_android": {}
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/modules/auth.js:
--------------------------------------------------------------------------------
1 | import { APP_VERSION_MAP, APP_KEY_MAP } from "./static.js";
2 | import { radioAreaId } from "./constants.js"
3 | import { genRandomInfo, genGPS } from "./util.js"
4 |
5 | /**
6 | * The max lifetime of a token is 90 mins. and Radiko web will refresh it after 70mins (42e5).
7 | *
8 | * Auth token generated with premium radiko_session cookie can access the tf30 resource.
9 | */
10 | export async function retrieve_token(radioname, default_area_id) {
11 | let availableArea = radioAreaId[radioname].area;
12 | let { auth_tokens: authTokens } = await chrome.storage.session.get({ "auth_tokens": {} });
13 |
14 | let hadTokenArea = availableArea.filter((area) => {
15 | return !!authTokens[area] && ((Date.now() - authTokens[area].requestTime) < 42e5);
16 | })
17 | if (hadTokenArea.length > 0) {
18 | let pickArea = hadTokenArea.includes(default_area_id) ? default_area_id : hadTokenArea[0];
19 | return [authTokens[pickArea].token, pickArea];
20 | } else {
21 | // if session has exist but expired areas, pick one, remove others, else get random one.
22 | let expired = availableArea.filter((area) => {
23 | return !!authTokens[area]
24 | })
25 | let pickArea;
26 | if (expired.length > 0) {
27 | pickArea = expired.pop();
28 | for (let i in expired) {
29 | delete authTokens[i];
30 | }
31 | } else {
32 | pickArea = availableArea[(Math.floor(Math.random() * availableArea.length)) >> 0];
33 | }
34 |
35 | let info = genRandomInfo();
36 | let rapp = APP_VERSION_MAP[info.appversion];
37 | let auth1 = await fetch("https://radiko.jp/v2/api/auth1", {
38 | headers: {
39 | 'X-Radiko-App': rapp,
40 | 'X-Radiko-App-Version': info.appversion,
41 | 'X-Radiko-Device': info.device,
42 | 'X-Radiko-User': info.userid,
43 | },
44 | });
45 |
46 | let token = auth1.headers.get('x-radiko-authtoken')
47 | let offset = parseInt(auth1.headers.get('x-radiko-keyoffset'));
48 | let length = parseInt(auth1.headers.get('x-radiko-keylength'));
49 | let partial = btoa(atob(APP_KEY_MAP[rapp]).slice(offset, offset + length));
50 | let auth2 = await fetch('https://radiko.jp/v2/api/auth2', {
51 | headers: {
52 | 'X-Radiko-App': rapp,
53 | 'X-Radiko-App-Version': info.appversion,
54 | 'X-Radiko-Device': info.device,
55 | 'X-Radiko-User': info.userid,
56 | 'X-Radiko-AuthToken': token,
57 | 'X-Radiko-Partialkey': partial,
58 | 'X-Radiko-Location': genGPS(pickArea),
59 | },
60 | })
61 | if (auth2.status == 200) {
62 | authTokens[pickArea] = { token: token, requestTime: Date.now() };
63 | await chrome.storage.session.set({ "auth_tokens": authTokens });
64 | return [token, pickArea];
65 | } else {
66 | throw new Error("Retrieve token failed");
67 | }
68 | }
69 | }
70 |
71 |
72 | /**
73 | * The auth flow on Android App
74 | * note: userid is 16hex (not 32)
75 | * note: lat,lang is float (not string)
76 | *
77 | * Android app will use api.annex.radiko.jp (and protobuf) for further commands?
78 | * protobuf details could be found in mobile website
79 | *
80 | * Analysis with FakeTraveler and PCAPdroid-mitm under root
81 | */
82 | async function apk_auth(area_id, info) {
83 | let rapp = APP_VERSION_MAP[info.appversion];
84 | // Android VERSION_MAP's key ' split("dot")[0]
85 | // Device like Google Pixel 6
86 | let ua = `radiko/${info.appversion} (Android;Android14, Google Pixel 6)`
87 | let auth1 = await fetch("https://api.radiko.jp/apparea/auth1", {
88 | method: "POST",
89 | headers: {
90 | "Content-Type": "application/json",
91 | "User-Agent": ua
92 | },
93 | body: JSON.stringify({ "app_id": rapp, "app_version": info.appversion, "user_id": info.userid.slice(0, 16), "device": "android" }
94 | )
95 | });
96 | // Response sample {"app_type":"android","auth_token_info":{"auth_token":"","expires_at":"2025-01-01T00:00:00+09:00"},"delay":15,"key_length":16,"key_offset":18319,"tet_type":"android"}
97 | // `expires_at` shows -> 90 mins lifetime
98 | let resp = await auth1.json();
99 | let token = resp["auth_token_info"]["auth_token"];
100 | let requestTime = Date.parse(auth1.headers.get("date"));
101 | let length = resp["key_length"];
102 | let offset = resp["key_offset"];
103 | let partial = btoa(atob(APP_KEY_MAP[rapp]).slice(offset, offset + length));
104 |
105 | // TODO: new genGPS function -> return Float!
106 | let gps = genGPS(area_id).split(",");
107 | let lat = parseFloat(gps[0]);
108 | let lang = parseFloat(gps[1]);
109 |
110 | let auth2 = await fetch("https://api.radiko.jp/apparea/auth2", {
111 | method: "POST",
112 | headers: {
113 | "Content-Type": "application/json",
114 | "User-Agent": ua
115 | },
116 | body: JSON.stringify({
117 | "auth_token": token, "partial_key": partial, "connection": "wifi",
118 | "location": { "latitude": lat, "longitude": lang }
119 | })
120 | });
121 | // Intersting error message: (when i pass string in lat,lang)
122 | // message "json error: json: cannot unmarshal string into Go struct field Location.location.latitude of type float64"
123 | let resp2 = await auth2.json();
124 | // Response: { "areas": [ { "area_id": "JP13", "area_name": "東京都", "area_roman": "tokyo Japan" } ], "is_areafree": false, "is_out": false, "is_timefree_plus": false }
125 | // authTokens[pickArea] = { token: token, requestTime: requestTime };
126 | // await chrome.storage.session.set({ "auth_tokens": authTokens });
127 |
128 | }
129 |
130 | // Seem from yt-dlp-rajiko
131 | async function auth_check(token) {
132 | let resp = await fetch("https://radiko.jp/v2/api/auth_check", { headers: { 'X-Radiko-AuthToken': token } });
133 | if (resp.status == 401) { return false; }
134 | let text = await resp.text();
135 | if (text != "OK") { return false; }
136 | return true;
137 | }
--------------------------------------------------------------------------------
/modules/constants.js:
--------------------------------------------------------------------------------
1 | // constant values for background and popup
2 | export const regions = [
3 | { id: "hokkaido-tohoku", name: "北海道・東北" },
4 | { id: "kanto", name: "関東" },
5 | { id: "hokuriku-koushinetsu", name: "北陸・甲信越" },
6 | { id: "chubu", name: "中部" },
7 | { id: "kinki", name: "近畿" },
8 | { id: "chugoku-shikoku", name: "中国・四国" },
9 | { id: "kyushu", name: "九州・沖縄" }];
10 |
11 | //http://radiko.jp/v3/station/region/full.xml
12 |
13 |
14 | //common.js
15 | export const areaList = ['北海道', '青森', '岩手', '宮城', '秋田', '山形', '福島', '茨城', '栃木', '群馬', '埼玉', '千葉', '東京', '神奈川', '新潟', '富山', '石川', '福井', '山梨', '長野', '岐阜', '静岡', '愛知', '三重', '滋賀', '京都', '大阪', '兵庫', '奈良', '和歌山', '鳥取', '島根', '岡山', '広島', '山口', '徳島', '香川', '愛媛', '高知', '福岡', '佐賀', '長崎', '熊本', '大分', '宮崎', '鹿児島', '沖縄'];
16 | // TODO: Firefox Only
17 | export const areaSuffixList = ['', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '都', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '府', '府', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県'];
18 |
19 |
20 | // Only used for Firefox filterResponseData to pass area check API
21 | // https://www.iso.org/obp/ui/#iso:code:3166:JP With language code: en
22 | // Note: auth2 return KOUCHI instead of KOCHI
23 | export const areaMap = { "JP1": "HOKKAIDO", "JP2": "AOMORI", "JP3": "IWATE", "JP4": "MIYAGI", "JP5": "AKITA", "JP6": "YAMAGATA", "JP7": "FUKUSHIMA", "JP8": "IBARAKI", "JP9": "TOCHIGI", "JP10": "GUNMA", "JP11": "SAITAMA", "JP12": "CHIBA", "JP13": "TOKYO", "JP14": "KANAGAWA", "JP15": "NIIGATA", "JP16": "TOYAMA", "JP17": "ISHIKAWA", "JP18": "FUKUI", "JP19": "YAMANASHI", "JP20": "NAGANO", "JP21": "GIFU", "JP22": "SHIZUOKA", "JP23": "AICHI", "JP24": "MIE", "JP25": "SHIGA", "JP26": "KYOTO", "JP27": "OSAKA", "JP28": "HYOGO", "JP29": "NARA", "JP30": "WAKAYAMA", "JP31": "TOTTORI", "JP32": "SHIMANE", "JP33": "OKAYAMA", "JP34": "HIROSHIMA", "JP35": "YAMAGUCHI", "JP36": "TOKUSHIMA", "JP37": "KAGAWA", "JP38": "EHIME", "JP39": "KOCHI", "JP40": "FUKUOKA", "JP41": "SAGA", "JP42": "NAGASAKI", "JP43": "KUMAMOTO", "JP44": "OITA", "JP45": "MIYAZAKI", "JP46": "KAGOSHIMA", "JP47": "OKINAWA" };
24 |
25 | export const areaListParRegion = {
26 | 'hokkaido-tohoku': [
27 | {
28 | id: 'JP1',
29 | name: '北海道'
30 | },
31 | {
32 | id: 'JP2',
33 | name: '青森'
34 | },
35 | {
36 | id: 'JP3',
37 | name: '岩手'
38 | },
39 | {
40 | id: 'JP4',
41 | name: '宮城'
42 | },
43 | {
44 | id: 'JP5',
45 | name: '秋田'
46 | },
47 | {
48 | id: 'JP6',
49 | name: '山形'
50 | },
51 | {
52 | id: 'JP7',
53 | name: '福島'
54 | }
55 | ],
56 | 'kanto': [
57 | {
58 | id: 'JP8',
59 | name: '茨城'
60 | },
61 | {
62 | id: 'JP9',
63 | name: '栃木'
64 | },
65 | {
66 | id: 'JP10',
67 | name: '群馬'
68 | },
69 | {
70 | id: 'JP11',
71 | name: '埼玉'
72 | },
73 | {
74 | id: 'JP12',
75 | name: '千葉'
76 | },
77 | {
78 | id: 'JP13',
79 | name: '東京'
80 | },
81 | {
82 | id: 'JP14',
83 | name: '神奈川'
84 | }
85 | ],
86 | 'hokuriku-koushinetsu': [
87 | {
88 | id: 'JP15',
89 | name: '新潟'
90 | },
91 | {
92 | id: 'JP19',
93 | name: '山梨'
94 | },
95 | {
96 | id: 'JP20',
97 | name: '長野'
98 | },
99 | {
100 | id: 'JP17',
101 | name: '石川'
102 | },
103 | {
104 | id: 'JP16',
105 | name: '富山'
106 | },
107 | {
108 | id: 'JP18',
109 | name: '福井'
110 | }
111 | ],
112 | 'chubu': [
113 | {
114 | id: 'JP23',
115 | name: '愛知'
116 | },
117 | {
118 | id: 'JP21',
119 | name: '岐阜'
120 | },
121 | {
122 | id: 'JP22',
123 | name: '静岡'
124 | },
125 | {
126 | id: 'JP24',
127 | name: '三重'
128 | }
129 | ],
130 | 'kinki': [
131 | {
132 | id: 'JP27',
133 | name: '大阪'
134 | },
135 | {
136 | id: 'JP28',
137 | name: '兵庫'
138 | },
139 | {
140 | id: 'JP26',
141 | name: '京都'
142 | },
143 | {
144 | id: 'JP25',
145 | name: '滋賀'
146 | },
147 | {
148 | id: 'JP29',
149 | name: '奈良'
150 | },
151 | {
152 | id: 'JP30',
153 | name: '和歌山'
154 | }
155 | ],
156 | 'chugoku-shikoku': [
157 | {
158 | id: 'JP33',
159 | name: '岡山'
160 | },
161 | {
162 | id: 'JP34',
163 | name: '広島'
164 | },
165 | {
166 | id: 'JP31',
167 | name: '鳥取'
168 | },
169 | {
170 | id: 'JP32',
171 | name: '島根'
172 | },
173 | {
174 | id: 'JP35',
175 | name: '山口'
176 | },
177 | {
178 | id: 'JP37',
179 | name: '香川'
180 | },
181 | {
182 | id: 'JP36',
183 | name: '徳島'
184 | },
185 | {
186 | id: 'JP38',
187 | name: '愛媛'
188 | },
189 | {
190 | id: 'JP39',
191 | name: '高知'
192 | }
193 | ],
194 | 'kyushu': [
195 | {
196 | id: 'JP40',
197 | name: '福岡'
198 | },
199 | {
200 | id: 'JP41',
201 | name: '佐賀'
202 | },
203 | {
204 | id: 'JP42',
205 | name: '長崎'
206 | },
207 | {
208 | id: 'JP43',
209 | name: '熊本'
210 | },
211 | {
212 | id: 'JP44',
213 | name: '大分'
214 | },
215 | {
216 | id: 'JP45',
217 | name: '宮崎'
218 | },
219 | {
220 | id: 'JP46',
221 | name: '鹿児島'
222 | },
223 | {
224 | id: 'JP47',
225 | name: '沖縄'
226 | }
227 | ]
228 | };
229 |
230 |
231 | /*
232 | from urllib.request import urlopen
233 | import re,json
234 | d = {}
235 | footer = urlopen('https://radiko.jp/apps/js/footer/base.js').read().decode("utf-8")
236 | radio_name=re.findall(r"{ id: ['\"](.*?)['\"], name: ['\"](.*?)['\"] }",footer)
237 | for i in radio_name:
238 | radio = i[0]
239 | name = i[1]
240 | area = re.findall(r'/index/(JP\d*)/',re.findall('channel-detail-info__list.*?',urlopen('http://radiko.jp/index/'+radio).read().decode("utf-8"),re.S)[0],re.S)
241 | d[radio] = {"name":name, "area":area}
242 | print(json.dumps(d,ensure_ascii=False))
243 | */
244 | // update details refer to https://radiko.jp/#!/info/ and https://radiko.jp/#!/news_release
245 | // TODO: ability to update runtime. however service worker doesn't support parsing dom/xml.
246 | // Ref: https://radiko.jp/v3/station/region/full.xml
247 | // Special update: "MAJAL": { "name": "MAJ 2025 AUDIO LIVE", "area": ["JP26"] }
248 | export const radioAreaId = { "MAJAL": { "name": "MAJ 2025 AUDIO LIVE", "area": ["JP26"] }, "HBC": { "name": "\uff28\uff22\uff23\u30e9\u30b8\u30aa", "area": ["JP1"] }, "STV": { "name": "\uff33\uff34\uff36\u30e9\u30b8\u30aa", "area": ["JP1"] }, "AIR-G": { "name": "AIR-G'\uff08FM\u5317\u6d77\u9053\uff09", "area": ["JP1"] }, "NORTHWAVE": { "name": "FM NORTH WAVE", "area": ["JP1"] }, "RAB": { "name": "\uff32\uff21\uff22\u9752\u68ee\u653e\u9001", "area": ["JP2"] }, "AFB": { "name": "\u30a8\u30d5\u30a8\u30e0\u9752\u68ee", "area": ["JP2"] }, "IBC": { "name": "IBC\u30e9\u30b8\u30aa", "area": ["JP3"] }, "FMI": { "name": "\u30a8\u30d5\u30a8\u30e0\u5ca9\u624b", "area": ["JP3"] }, "TBC": { "name": "TBC\u30e9\u30b8\u30aa", "area": ["JP4"] }, "DATEFM": { "name": "Date fm\uff08\u30a8\u30d5\u30a8\u30e0\u4ed9\u53f0\uff09", "area": ["JP4"] }, "ABS": { "name": "ABS\u30e9\u30b8\u30aa", "area": ["JP5"] }, "AFM": { "name": "\u30a8\u30d5\u30a8\u30e0\u79cb\u7530", "area": ["JP5"] }, "YBC": { "name": "YBC\u5c71\u5f62\u653e\u9001", "area": ["JP6"] }, "RFM": { "name": "Rhythm Station\u3000\u30a8\u30d5\u30a8\u30e0\u5c71\u5f62", "area": ["JP6"] }, "RFC": { "name": "RFC\u30e9\u30b8\u30aa\u798f\u5cf6", "area": ["JP7"] }, "FMF": { "name": "\u3075\u304f\u3057\u307eFM", "area": ["JP7"] }, "JOIK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u672d\u5e4c\uff09", "area": ["JP1"] }, "JOHK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u4ed9\u53f0\uff09", "area": ["JP2", "JP3", "JP4", "JP5", "JP6", "JP7"] }, "TBS": { "name": "TBS\u30e9\u30b8\u30aa", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "QRR": { "name": "\u6587\u5316\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "LFR": { "name": "\u30cb\u30c3\u30dd\u30f3\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "INT": { "name": "interfm", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "FMT": { "name": "TOKYO FM", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "FMJ": { "name": "J-WAVE", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "JORF": { "name": "\u30e9\u30b8\u30aa\u65e5\u672c", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "BAYFM78": { "name": "BAYFM78", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "NACK5": { "name": "NACK5", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "YFM": { "name": "\uff26\uff2d\u30e8\u30b3\u30cf\u30de", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "IBS": { "name": "LuckyFM \u8328\u57ce\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "CRT": { "name": "CRT\u6803\u6728\u653e\u9001", "area": ["JP9"] }, "RADIOBERRY": { "name": "RADIO BERRY", "area": ["JP9"] }, "FMGUNMA": { "name": "FM GUNMA", "area": ["JP10"] }, "JOAK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u6771\u4eac\uff09", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP19", "JP20"] }, "BSN": { "name": "\uff22\uff33\uff2e\u30e9\u30b8\u30aa", "area": ["JP15"] }, "FMNIIGATA": { "name": "FM NIIGATA", "area": ["JP15"] }, "KNB": { "name": "\uff2b\uff2e\uff22\u30e9\u30b8\u30aa", "area": ["JP16"] }, "FMTOYAMA": { "name": "\uff26\uff2d\u3068\u3084\u307e", "area": ["JP16"] }, "MRO": { "name": "MRO\u30e9\u30b8\u30aa", "area": ["JP17"] }, "HELLOFIVE": { "name": "\u30a8\u30d5\u30a8\u30e0\u77f3\u5ddd", "area": ["JP17"] }, "FBC": { "name": "FBC\u30e9\u30b8\u30aa", "area": ["JP18"] }, "FMFUKUI": { "name": "FM\u798f\u4e95", "area": ["JP18"] }, "YBS": { "name": "YBS\u30e9\u30b8\u30aa", "area": ["JP19"] }, "FM-FUJI": { "name": "FM FUJI", "area": ["JP19"] }, "SBC": { "name": "SBC\u30e9\u30b8\u30aa", "area": ["JP20"] }, "FMN": { "name": "\uff26\uff2d\u9577\u91ce", "area": ["JP20"] }, "JOCK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u540d\u53e4\u5c4b\uff09", "area": ["JP16", "JP17", "JP18", "JP21", "JP22", "JP23", "JP24"] }, "CBC": { "name": "CBC\u30e9\u30b8\u30aa", "area": ["JP21", "JP23", "JP24"] }, "TOKAIRADIO": { "name": "TOKAI RADIO", "area": ["JP21", "JP23", "JP24"] }, "GBS": { "name": "\u304e\u3075\u30c1\u30e3\u30f3", "area": ["JP21", "JP23", "JP24"] }, "ZIP-FM": { "name": "ZIP-FM", "area": ["JP21", "JP23", "JP24"] }, "FMAICHI": { "name": "FM AICHI", "area": ["JP21", "JP23", "JP24"] }, "FMGIFU": { "name": "\uff26\uff2d \uff27\uff29\uff26\uff35", "area": ["JP21"] }, "SBS": { "name": "SBS\u30e9\u30b8\u30aa", "area": ["JP22"] }, "K-MIX": { "name": "K-MIX", "area": ["JP22"] }, "FMMIE": { "name": "\u30ec\u30c7\u30a3\u30aa\u30ad\u30e5\u30fc\u30d6 \uff26\uff2d\u4e09\u91cd", "area": ["JP24"] }, "ABC": { "name": "ABC\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "MBS": { "name": "MBS\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "OBC": { "name": "OBC\u30e9\u30b8\u30aa\u5927\u962a", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "CCL": { "name": "FM COCOLO", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "802": { "name": "FM802", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "FMO": { "name": "FM\u5927\u962a", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "CRK": { "name": "\u30e9\u30b8\u30aa\u95a2\u897f", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "KISSFMKOBE": { "name": "Kiss FM KOBE", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "E-RADIO": { "name": "e-radio FM\u6ecb\u8cc0", "area": ["JP25"] }, "KBS": { "name": "KBS\u4eac\u90fd\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27"] }, "ALPHA-STATION": { "name": "\u03b1-STATION FM KYOTO", "area": ["JP25", "JP26", "JP27", "JP29"] }, "WBS": { "name": "wbs\u548c\u6b4c\u5c71\u653e\u9001", "area": ["JP30"] }, "JOBK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u5927\u962a\uff09", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "BSS": { "name": "BSS\u30e9\u30b8\u30aa", "area": ["JP31", "JP32"] }, "FM-SANIN": { "name": "\u30a8\u30d5\u30a8\u30e0\u5c71\u9670", "area": ["JP31", "JP32"] }, "RSK": { "name": "\uff32\uff33\uff2b\u30e9\u30b8\u30aa", "area": ["JP33"] }, "FM-OKAYAMA": { "name": "\uff26\uff2d\u5ca1\u5c71", "area": ["JP33"] }, "RCC": { "name": "RCC\u30e9\u30b8\u30aa", "area": ["JP34"] }, "HFM": { "name": "\u5e83\u5cf6FM", "area": ["JP34"] }, "KRY": { "name": "\uff2b\uff32\uff39\u5c71\u53e3\u653e\u9001", "area": ["JP35"] }, "FMY": { "name": "\u30a8\u30d5\u30a8\u30e0\u5c71\u53e3", "area": ["JP35"] }, "JRT": { "name": "\uff2a\uff32\uff34\u56db\u56fd\u653e\u9001", "area": ["JP36"] }, "FM807": { "name": "FM\u5fb3\u5cf6", "area": ["JP36"] }, "RNC": { "name": "RNC\u897f\u65e5\u672c\u653e\u9001", "area": ["JP37"] }, "FMKAGAWA": { "name": "FM\u9999\u5ddd", "area": ["JP37"] }, "RNB": { "name": "RNB\u5357\u6d77\u653e\u9001", "area": ["JP38"] }, "JOEU-FM": { "name": "FM\u611b\u5a9b", "area": ["JP38"] }, "RKC": { "name": "RKC\u9ad8\u77e5\u653e\u9001", "area": ["JP39"] }, "HI-SIX": { "name": "\u30a8\u30d5\u30a8\u30e0\u9ad8\u77e5", "area": ["JP39"] }, "JOFK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u5e83\u5cf6\uff09", "area": ["JP31", "JP32", "JP33", "JP34", "JP35"] }, "JOZK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u677e\u5c71\uff09", "area": ["JP36", "JP37", "JP38", "JP39"] }, "RKB": { "name": "RKB\u30e9\u30b8\u30aa", "area": ["JP40", "JP41"] }, "KBC": { "name": "KBC\u30e9\u30b8\u30aa", "area": ["JP40", "JP41"] }, "LOVEFM": { "name": "LOVE FM", "area": ["JP40"] }, "CROSSFM": { "name": "CROSS FM", "area": ["JP40"] }, "FMFUKUOKA": { "name": "FM FUKUOKA", "area": ["JP40"] }, "FMS": { "name": "\u30a8\u30d5\u30a8\u30e0\u4f50\u8cc0", "area": ["JP41"] }, "NBC": { "name": "NBC\u30e9\u30b8\u30aa", "area": ["JP41", "JP42"] }, "FMNAGASAKI": { "name": "FM\u9577\u5d0e", "area": ["JP42"] }, "RKK": { "name": "RKK\u30e9\u30b8\u30aa", "area": ["JP43"] }, "FMK": { "name": "FMK\u30a8\u30d5\u30a8\u30e0\u718a\u672c", "area": ["JP43"] }, "OBS": { "name": "OBS\u30e9\u30b8\u30aa", "area": ["JP44"] }, "FM_OITA": { "name": "\u30a8\u30d5\u30a8\u30e0\u5927\u5206", "area": ["JP44"] }, "MRT": { "name": "\u5bae\u5d0e\u653e\u9001", "area": ["JP45"] }, "JOYFM": { "name": "\u30a8\u30d5\u30a8\u30e0\u5bae\u5d0e", "area": ["JP45"] }, "MBC": { "name": "\uff2d\uff22\uff23\u30e9\u30b8\u30aa", "area": ["JP46"] }, "MYUFM": { "name": "\u03bc\uff26\uff2d", "area": ["JP46"] }, "RBC": { "name": "RBCi\u30e9\u30b8\u30aa", "area": ["JP47"] }, "ROK": { "name": "\u30e9\u30b8\u30aa\u6c96\u7e04", "area": ["JP47"] }, "FM_OKINAWA": { "name": "FM\u6c96\u7e04", "area": ["JP47"] }, "JOLK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u798f\u5ca1\uff09", "area": ["JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "RN1": { "name": "\u30e9\u30b8\u30aaNIKKEI\u7b2c1", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "RN2": { "name": "\u30e9\u30b8\u30aaNIKKEI\u7b2c2", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "JOAK-FM": { "name": "NHK-FM\uff08\u6771\u4eac\uff09", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] } }
249 | // For calcuating RULEID
250 | export const radioIndex = Object.keys(radioAreaId)
--------------------------------------------------------------------------------
/modules/recording.js:
--------------------------------------------------------------------------------
1 | import { str2ab, ab2str, parseAAC, getBlobUrl, revokeBlobUrl, initiatorFromExtension } from "./util.js"
2 |
3 | /**
4 | * Return datetime string in Asia/Tokyo timezone!
5 | */
6 | function timestamp2Filename(t) {
7 | // Intl format to 'YYYY/MM/dd hh:mm:ss'
8 | return Intl.DateTimeFormat("ja",
9 | {
10 | timeZone: "Asia/Tokyo",
11 | year: "numeric", month: "2-digit", day: "2-digit",
12 | hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit"
13 | }).format(new Date(t)).replaceAll("/", "").replaceAll(":", "").replaceAll(" ", "");
14 | }
15 |
16 | export function stream_listener_builder(radioname) {
17 | // Mostly for preparing recording.
18 | // https://stackoverflow.com/questions/66618136/persistent-service-worker-in-chrome-extension/
19 | // Chrome blog: The service worker terminates after 30 seconds of inactivity. (Receiving an event or calling an extension API resets this timer).
20 | // https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers#keep_a_service_worker_alive_until_a_long-running_operation_is_finished
21 | let heartbeatInterval;
22 |
23 | async function runHeartbeat() {
24 | await chrome.storage.session.set({ "last-heartbeat": Date.now() });
25 | }
26 |
27 | runHeartbeat().then(() => {
28 | // Then again every 20 seconds.
29 | heartbeatInterval = setInterval(runHeartbeat, 20 * 1000);
30 | });
31 |
32 | let started = false;
33 | let count = 0;
34 | let start_time;
35 | let end_time
36 |
37 | async function listener(req) {
38 | if (initiatorFromExtension(req)) { return; }
39 | end_time = Date.now();
40 | if (!started) {
41 | started = true;
42 | start_time = end_time;
43 | } //seems like jump-back problem disappered!
44 |
45 | // Due to radiko aac response's cache-control:max-age=0 , we must download it again. no disk cache available.
46 | let resp = await fetch(req.url);
47 | let data = await resp.arrayBuffer();
48 |
49 | let audio_string = ab2str(data, parseAAC(data)[0]); //timestamp not used for now.
50 | let storage_set = {};
51 | storage_set[`${radioname}_${start_time}_${count}`] = audio_string;
52 | count += 1;
53 |
54 | await chrome.storage.local.set(storage_set);
55 | };
56 |
57 | chrome.runtime.onMessage.addListener(async function stopme(msg, sender, respCallback) {
58 | if (msg["stop-recording"]) {
59 | chrome.runtime.onMessage.removeListener(stopme);
60 | // Note: change from onBeforeSendHeaders to onCompleted
61 | chrome.webRequest.onCompleted.removeListener(listener);
62 |
63 | clearInterval(heartbeatInterval);
64 | await chrome.storage.session.remove("last-heartbeat");
65 |
66 | if (count == 0) {
67 | await chrome.storage.local.remove("current_recording");
68 | return;
69 | }
70 | let filename = `${radioname}/${radioname}_${timestamp2Filename(start_time)}_${timestamp2Filename(end_time)}.aac`;
71 |
72 | let keyList = Array.from({ length: count }, (_, idx) => `${radioname}_${start_time}_${idx}`);
73 | let data = await chrome.storage.local.get(keyList);
74 | if (data) {
75 | let audio_buf = keyList.map(function (x) {
76 | return str2ab(data[x]);
77 | });
78 |
79 | let audiodata = new Blob(audio_buf, {
80 | type: "audio/aac"
81 | });
82 | let audiourl = await getBlobUrl(audiodata);
83 | chrome.downloads.onChanged.addListener(async function handler(delta) {
84 | if (delta.id == downloadId && delta.state && delta.state.current === "complete") {
85 | chrome.downloads.onChanged.removeListener(handler);
86 | await chrome.storage.local.remove(keyList);
87 | await chrome.storage.session.remove("current_recording");
88 |
89 | await revokeBlobUrl(audiourl);
90 | }
91 | });
92 | let downloadId = await chrome.downloads.download({
93 | url: audiourl,
94 | filename: filename
95 | });
96 | }
97 | chrome.action.setIcon?.({
98 | path: 'Circle-icons-radio-blue-48.png'
99 | });
100 | }
101 | });
102 |
103 | return listener;
104 | }
--------------------------------------------------------------------------------
/modules/rules.js:
--------------------------------------------------------------------------------
1 | import { radioIndex } from "./constants.js"
2 | import { PLAYER_RULE_TEMPLATE, TEMPLATE_RADIO_NAME, RULEID, NHK_PERMISSION, JAPAN_IPS, APP_VERSION_MAP, RECOCHOKU_PERMISSION, TVER_PERMISSION } from "./static.js";
3 | import { genRandomIp } from "./util.js"
4 |
5 | /**
6 | * The max rule number is radioIndex.length * PLAYER_RULE_TEMPLATE.length
7 | * For now it is 109 * 5 = 540
8 | * TODO what about rule expired?
9 | * TODO what if there's a new radio. Fetch https://radiko.jp/index/RADIONAME then parse DOM in offscreen
10 | */
11 | export function updateRadioRules(radioname, area_id, token) {
12 | let idx = radioIndex.indexOf(radioname);
13 | let rules = { addRules: [], removeRuleIds: [] };
14 | let rules_count = PLAYER_RULE_TEMPLATE.length;
15 |
16 | for (let i = 0; i < rules_count; i++) {
17 | let id = RULEID.RADIO_BASE + rules_count * idx + i;
18 | let urlfilter = PLAYER_RULE_TEMPLATE[i].replaceAll(TEMPLATE_RADIO_NAME, radioname)
19 | rules.addRules.push({
20 | id: id,
21 | action: {
22 | type: "modifyHeaders",
23 | requestHeaders: [
24 | {
25 | header: "X-Radiko-AuthToken",
26 | operation: "set",
27 | value: token
28 | },
29 | {
30 | header: "X-Radiko-AreaId",
31 | operation: "set",
32 | value: area_id
33 | },
34 | ]
35 | },
36 | condition: {
37 | excludedInitiatorDomains: [chrome.runtime.id],
38 | urlFilter: urlfilter,
39 | }
40 | });
41 | rules.removeRuleIds.push(id);
42 | }
43 | console.log("updateRadioRules ", radioname, " with ", area_id, " rules:", rules);
44 | chrome.declarativeNetRequest.updateSessionRules(rules);
45 | }
46 |
47 | /**
48 | * Rules for NHK Radio.
49 | * These rules apply on Firefox and Chrome.
50 | */
51 | export async function setUpNHKRadio(enabled) {
52 | if (enabled === true) {
53 | let matched = await chrome.permissions.contains(NHK_PERMISSION);
54 | if (!matched) {
55 | // reset to disabled
56 | await chrome.storage.local.set({ "nhkradio_bypass": false });
57 | return;
58 | }
59 | let japan_ip = genRandomIp(JAPAN_IPS);
60 | console.log(`using japan ip ${japan_ip}`);
61 | chrome.declarativeNetRequest.updateSessionRules({
62 | addRules: [
63 | {
64 | id: RULEID.NHK_RADIO_LIVE,
65 | action: {
66 | type: "modifyHeaders",
67 | requestHeaders: [
68 | {
69 | header: "X-Forwarded-For",
70 | operation: "set",
71 | value: japan_ip
72 | }
73 | ]
74 | },
75 | condition: {
76 | // Only apply on nhk.or.jp site.
77 | initiatorDomains: ["nhk.or.jp"],
78 | urlFilter: "*://*.nhk.jp/hls/*"
79 | }
80 | },
81 | {
82 | id: RULEID.NHK_RADIO_VOD,
83 | action: {
84 | type: "modifyHeaders",
85 | requestHeaders: [
86 | {
87 | header: "X-Forwarded-For",
88 | operation: "set",
89 | value: japan_ip
90 | }
91 | ]
92 | },
93 | condition: {
94 |
95 | initiatorDomains: ["nhk.or.jp"],
96 | urlFilter: "*://vod-stream.nhk.jp/*"
97 | }
98 | }],
99 | removeRuleIds: [RULEID.NHK_RADIO_LIVE, RULEID.NHK_RADIO_VOD]
100 | });
101 |
102 | } else {
103 | // Should check if exists?
104 | chrome.declarativeNetRequest.updateSessionRules({
105 | removeRuleIds: [RULEID.NHK_RADIO_LIVE, RULEID.NHK_RADIO_VOD]
106 | });
107 | }
108 | }
109 |
110 |
111 | /**
112 | * Rules for TVer UI fix.
113 | * These rules apply on Firefox and Chrome.
114 | */
115 | export async function setUpTVer(enabled) {
116 | if (enabled === true) {
117 | let matched = await chrome.permissions.contains(TVER_PERMISSION);
118 | if (!matched) {
119 | // reset to disabled
120 | await chrome.storage.local.set({ "tver_fix": false });
121 | return;
122 | }
123 |
124 | // Tver treats Chrome under Linux as AndroidPC and then askes user to use its App.
125 | // NOTE: if using manifest-> "incognito": "split"
126 | // (for opening url in incognito tab instead of normal tab when clicking link in popup meu under incognito window),
127 | // add chrome.extension.inIncognitoContext in script id to avoid duplication.
128 | let info = await chrome.runtime.getPlatformInfo();
129 |
130 | // Edge for Android can install extensions too.
131 | if (info.os == "linux" || info.os == "android") {
132 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["tver_playable_ua"] });
133 | if (result && result.length > 0) {
134 | // Already registered
135 | return;
136 | }
137 |
138 | let script = {
139 | id: "tver_playable_ua",
140 | js: ["ui/tver_playable_ua_inspect.js"],
141 | matches: ["https://*.tver.jp/*"],
142 | // Keypoint 1: run before `getEnvType` in Tver.
143 | runAt: "document_start",
144 | // Keypoint 2: don't isolate.
145 | world: "MAIN"
146 | }
147 | // Edge for Android can install extensions too.
148 | if ((info.os == "android")) {
149 | script.css = ["ui/tver_playable_mobile.css"];
150 | }
151 |
152 | await chrome.scripting.registerContentScripts([script]);
153 | }
154 |
155 | } else {
156 | let info = await chrome.runtime.getPlatformInfo();
157 | // Edge for Android can install extensions too.
158 | if (info.os == "linux" || info.os == "android") {
159 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["tver_playable_ua"] });
160 | if (result && result.length > 0) {
161 | // Already registered
162 | await chrome.scripting.unregisterContentScripts({ ids: ["tver_playable_ua"] });
163 | }
164 | }
165 | }
166 | }
167 |
168 |
169 | /**
170 | * Rules for bypass Radiko area check and auth
171 | */
172 | // TODO update rules per tab ? but how to delete? or update rules per region and only apply to some tabs?
173 | export function updateAreaRules(area_id, info) {
174 | chrome.declarativeNetRequest.updateSessionRules(
175 | {
176 | addRules: [
177 | {
178 | // Use prepared response from `response` folder for RULEID.APPAREA, RULEID.AREA and RULEID.AUTH2.
179 | // I'm not sure why it works now.
180 | // As i can recall, previously 307 Internal Redirect is not a success code for `/area` API preflight.
181 | id: RULEID.APPAREA,
182 | action: { type: "redirect", redirect: { extensionPath: "/response/area-" + area_id + ".html" } },
183 | condition: { urlFilter: "*://*.radiko.jp/apparea/area*" }
184 | },
185 | {
186 | id: RULEID.AREA,
187 | action: { type: "redirect", redirect: { extensionPath: "/response/area-" + area_id + ".html" } },
188 | condition: { urlFilter: "*://radiko.jp/area*" }
189 | },
190 | {
191 | id: RULEID.AUTH1,
192 | action: {
193 | type: "modifyHeaders",
194 | requestHeaders: [
195 | // Remove
196 | {
197 | header: "Accept-Language",
198 | operation: "remove"
199 | },
200 | {
201 | header: "Accept",
202 | operation: "remove"
203 | },
204 | {
205 | header: "Cookie",
206 | operation: "remove"
207 | },
208 | {
209 | header: "Referer",
210 | operation: "remove"
211 | },
212 | // Set
213 | {
214 | header: "X-Radiko-User",
215 | operation: "set",
216 | value: info.userid
217 | },
218 | {
219 | header: "X-Radiko-App-Version",
220 | operation: "set",
221 | value: info.appversion
222 | },
223 | {
224 | header: "X-Radiko-App",
225 | operation: "set",
226 | value: APP_VERSION_MAP[info.appversion]
227 | },
228 | {
229 | header: "X-Radiko-Device",
230 | operation: "set",
231 | value: info.device
232 | },
233 | {
234 | header: "User-Agent",
235 | operation: "set",
236 | value: info.useragent
237 | }
238 | ],
239 | responseHeaders: [
240 | {
241 | // to avoid too big offset causing radiko's js error
242 | // Will this affect the calculation in onHeadersReceived? -> no
243 | // Note: yes on Firefox , so we don't use area rules in firefox.
244 | header: "x-radiko-keyoffset",
245 | operation: "set",
246 | value: "0"
247 | }
248 | ]
249 | },
250 | condition: {
251 | // Exclude the req from extension
252 | excludedInitiatorDomains: [chrome.runtime.id],
253 | urlFilter: "*://radiko.jp/v2/api/auth1*"
254 | }
255 | },
256 | {
257 | id: RULEID.AUTH2,
258 | action: { type: "redirect", redirect: { extensionPath: "/response/auth2-" + area_id + ".html" } },
259 | condition: {
260 | // Exclude the req from extension
261 | excludedInitiatorDomains: [chrome.runtime.id],
262 | urlFilter: "*://radiko.jp/v2/api/auth2*"
263 | }
264 | },
265 | {
266 | id: RULEID.AUTH_FETCH,
267 | action: {
268 | type: "modifyHeaders",
269 | requestHeaders: [
270 | // Remove
271 | {
272 | header: "Accept-Language",
273 | operation: "remove"
274 | },
275 | {
276 | header: "Accept",
277 | operation: "remove"
278 | },
279 | {
280 | header: "User-Agent",
281 | operation: "set",
282 | value: info.useragent
283 | }
284 | ]
285 | },
286 | condition: {
287 | // Only for extension's Fetch to remove unnecessary headers.
288 | initiatorDomains: [chrome.runtime.id],
289 | urlFilter: "*://radiko.jp/v2/api/auth*"
290 | }
291 | }],
292 | removeRuleIds: [RULEID.APPAREA, RULEID.AREA, RULEID.AUTH1, RULEID.AUTH2, RULEID.AUTH_FETCH]
293 | }
294 | )
295 | }
296 |
297 |
298 | /**
299 | * script for rajiko on mobile
300 | * Edge for Android can install extensions too.
301 | */
302 | export async function setUpMobileRadiko() {
303 | let platform = await chrome.runtime.getPlatformInfo();
304 | if (platform.os == "android") {
305 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["radiko_mobile"] });
306 | if (result && result.length > 0) {
307 | // Already registered
308 | return;
309 | }
310 | await chrome.scripting.registerContentScripts([
311 | {
312 | id: "radiko_mobile",
313 | js: ["ui/mobile_start.js"],
314 | css: ["ui/mobile.css"],
315 | matches: ["https://*.radiko.jp/*"],
316 | runAt: "document_start",
317 | // Keypoint 2: don't isolate.
318 | world: "MAIN"
319 | }
320 | ]);
321 | }
322 | }
323 |
324 | export async function setUpRecochokuUserAgent(enabled) {
325 | if (enabled === true) {
326 | let matched = await chrome.permissions.contains(RECOCHOKU_PERMISSION);
327 | if (!matched) {
328 | // reset to disabled
329 | await chrome.storage.local.set({ "recochoku_ua": false });
330 | return;
331 | }
332 |
333 | chrome.declarativeNetRequest.updateSessionRules({
334 | addRules: [
335 | {
336 | id: RULEID.RECOCHOKU_USERAGENT,
337 | action: {
338 | type: "modifyHeaders",
339 | requestHeaders: [
340 | {
341 | header: "User-Agent",
342 | operation: "set",
343 | // or "wget"
344 | value: "curl"
345 | }
346 | ]
347 | },
348 | condition: {
349 | // main_frame.
350 | // css/js is also blocked.
351 | // and https://recochoku.jp/trial/music
352 | resourceTypes: ["main_frame", "stylesheet", "script", "xmlhttprequest", "image"],
353 | requestDomains: ["recochoku.jp"],
354 | }
355 | }
356 | ],
357 | removeRuleIds: [RULEID.RECOCHOKU_USERAGENT]
358 | });
359 |
360 |
361 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["recochoku_header_caution"] });
362 | if (result && result.length > 0) {
363 | // Already registered
364 | return;
365 | }
366 |
367 | await chrome.scripting.registerContentScripts([{
368 | id: "recochoku_header_caution",
369 | matches: ["https://*.recochoku.jp/*"],
370 | css: ["ui/recochoku_header_caution.css"],
371 | runAt: "document_start",
372 | }]);
373 |
374 | } else {
375 | chrome.declarativeNetRequest.updateSessionRules({
376 | removeRuleIds: [RULEID.RECOCHOKU_USERAGENT]
377 | });
378 |
379 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["recochoku_header_caution"] });
380 | if (result && result.length > 0) {
381 | // Already registered
382 | await chrome.scripting.unregisterContentScripts({ ids: ["recochoku_header_caution"] });
383 | }
384 | }
385 | }
386 |
--------------------------------------------------------------------------------
/modules/timeshift.js:
--------------------------------------------------------------------------------
1 |
2 | import { parseAAC, ab2str, str2ab, getBlobUrl, revokeBlobUrl } from "./util.js"
3 | import { retrieve_token } from "./auth.js"
4 |
5 |
6 |
7 | // https://stackoverflow.com/a/73517935
8 | async function worker(arr, func, limit = 5) {
9 | let results = [];
10 | let workers = [];
11 | let current = Math.min(arr.length, limit);
12 | async function process(i) {
13 | if (i < arr.length) {
14 | results[i] = await Promise.resolve(func(arr[i], i));
15 | await process(current++);
16 | }
17 | }
18 | for (let i = 0; i < current; i++) {
19 | workers.push(process(i));
20 | }
21 | await Promise.all(workers);
22 | return results;
23 | }
24 |
25 |
26 | async function cleanuptask(link) {
27 | let { timeshift_list: list } = await chrome.storage.local.get({ "timeshift_list": [] });
28 | list = list.filter(function (l) {
29 | return l !== link;
30 | });
31 | await chrome.storage.local.set({
32 | "timeshift_list": list,
33 | });
34 | chrome.action.setBadgeText?.({ text: list.length > 0 ? list.length.toString() : "" });
35 | }
36 |
37 | function toDate(programDate) {
38 | return new Date(`${programDate.slice(0, 4)}-${programDate.slice(4, 6)}-${programDate.slice(6, 8)}T${programDate.slice(8, 10)}:${programDate.slice(10, 12)}:${programDate.slice(12, 14)}.000`);
39 | }
40 |
41 | // Not used currently.
42 | function isTimefreePlus(ft) {
43 | let programDate = toDate(ft);
44 | programDate.setHours(programDate.getHours() - 5);
45 | programDate.setHours(0, 0, 0, 0);
46 |
47 | const options = {
48 | year: "numeric",
49 | month: "2-digit",
50 | day: "2-digit",
51 | timeZone: "Asia/Tokyo",
52 | hour12: false,
53 | };
54 | let dateTimeFormat = new Intl.DateTimeFormat('ja-jp', options);
55 | let parts = dateTimeFormat.formatToParts();
56 | let year, month, day;
57 | parts.map(part => {
58 | switch (part.type) {
59 | case "day":
60 | day = part.value;
61 | break;
62 | case "month":
63 | month = part.value;
64 | break;
65 | case "year":
66 | year = part.value;
67 | break;
68 | }
69 | });
70 | // Japan date , but treat as local datetime object.
71 | let today = new Date(`${year}-${month}-${day}T00:00:00.000`);
72 | today.setHours(today.getHours() - 5);
73 | today.setHours(0, 0, 0, 0);
74 | let diff = (today - programDate) / 1000.0 / 60 / 60 / 24;
75 | return diff > 7;
76 | }
77 |
78 | async function playlist_create_url(station_id) {
79 | let resp = await fetch(`https://radiko.jp/v3/station/stream/pc_html5/${station_id}.xml`);
80 | let data = await resp.text();
81 |
82 | // Sadly serice worker doesn't support parse xml.
83 |
84 | // attribute: timefree=1, because this is a timeshift
85 | // areafree=0, we use stations's local area id.
86 | const regex = /((areafree="0".*?timefree="1")|(timefree="1".*?areafree="0")).*\n.*(.*?)<\/playlist_create_url>/gm;
87 | try {
88 | // TODO well not safe....
89 | let match = regex.exec(data);
90 | return match[4];
91 | } catch {
92 | // fallback
93 | return "https://tf-f-rpaa-radiko.smartstream.ne.jp/tf/playlist.m3u8"
94 | }
95 | }
96 |
97 | /**
98 | *
99 | * @param {string} dt YYYYMMDDHHmmss style string
100 | * @param {int} l seek seconds
101 | *
102 | * returns an array of [seeked date, YYYYMMDDHHmmss style string]
103 | */
104 | function seek(dt, l) {
105 | dt.setSeconds(dt.getSeconds() + l);
106 | return [dt, `${dt.getFullYear()}${(dt.getMonth() + 1).toString().padStart(2, '0')}${dt.getDate().toString().padStart(2, '0')}${dt.getHours().toString().padStart(2, '0')}${dt.getMinutes().toString().padStart(2, '0')}${dt.getSeconds().toString().padStart(2, '0')}`]
107 | }
108 |
109 | /**
110 | * It looks like only https://radiko.jp/v2/api/ts/playlist.m3u8 API, returns full aac list
111 | * intermediate url: https://radiko.jp/v2/api/ts/chunklist/xxxxx.m3u8
112 | * Other endpoint returns `l` seconds.
113 | *
114 | * (Since this endpoint is for aSmartPhon7a)
115 | * radiko.jp/v2/api/ts/playlist.m3u8 API show expired seems that this API do not support older than 7days.
116 | * but other playlist_create_url show forbidden
117 | *
118 | * It is safe to treat non-tf30 program as tf30 program (maybe no, see below) , but reverse not.
119 | * so `isTimefreePlus` should be more strict
120 | *
121 | * Some program at girigiri 7 days , https://radiko.jp/v2/api/ts/playlist.m3u8 still work, but new api not work?
122 | */
123 | export async function downloadtimeShift(link, default_area_id, tf30) {
124 | let searchParams = (new URL(link)).searchParams;
125 | let radioname = searchParams.get("station_id");
126 | let from = searchParams.get("ft");
127 | let to = searchParams.get("to");
128 |
129 | let filename = radioname + '_' + from + '_' + to + '.aac';
130 | console.log(`timeshift file ${filename}`);
131 | let [token, area_id] = await retrieve_token(radioname, default_area_id);
132 |
133 | let links = [];
134 |
135 | /**
136 | * Get every 5s aac in a seek period(300s)
137 | */
138 | // if (isTimefreePlus(from)) {
139 | // Use the value from Radiko site.
140 | if (tf30) {
141 | // from yt-dlp-rajiko: the max accepted seek value.
142 | const FIXED_SEEK = 300;
143 | // whatever token has tf30 or not , just make a try
144 | let url = new URL(await playlist_create_url(radioname));
145 | let param = url.searchParams;
146 | param.set("lsid", (() => {
147 | let hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
148 | let s = '';
149 | for (let i = 0; i < 32; i++) {
150 | s += hex[(Math.floor(Math.random() * hex.length)) >> 0];
151 | }
152 | return s;
153 | })());
154 | param.set("station_id", radioname);
155 | param.set("l", FIXED_SEEK);
156 | param.set("start_at", from);
157 | param.set("end_at", to);
158 | // b for station in area , c for not ,see `connectionType`
159 | param.set("type", "b");
160 | // These are not necessary (but we should look the same)
161 | param.set("ft", from);
162 | param.set("to", to);
163 |
164 | // new style method like recording
165 | for (
166 | // Init
167 | let end_date = toDate(to), seek_str = from, seek_date = toDate(from);
168 | // Condtion
169 | seek_date < end_date;
170 | // Increment
171 | [seek_date, seek_str] = seek(seek_date, FIXED_SEEK)
172 | ) {
173 | param.set("seek", seek_str);
174 |
175 | let response = await fetch(url.toString(), {
176 | headers: {
177 | 'X-Radiko-AreaId': area_id,
178 | 'X-Radiko-AuthToken': token
179 | }
180 | });
181 |
182 | let resp = await response.text();
183 | if (!response.ok || response.status == 403 || resp == "expired") {
184 | await cleanuptask(link);
185 | return;
186 | }
187 | let detailLink = resp.split('\n').filter(function (d) {
188 | return d[0] != '#' && d.trim() != '';
189 | })[0];
190 |
191 | let response2 = await fetch(detailLink);
192 |
193 | let resp2 = await response2.text();
194 | let partLinks = resp2.split('\n').filter(function (d) {
195 | return d[0] != '#' && d.trim() != '';
196 | });
197 | links.push(...partLinks);
198 | }
199 |
200 | } else {
201 | let response = await fetch(link, {
202 | headers: {
203 | 'X-Radiko-AreaId': area_id,
204 | 'X-Radiko-AuthToken': token
205 | }
206 | });
207 |
208 | let resp = await response.text();
209 |
210 | if (!response.ok || response.status == 403 || resp == "expired") {
211 | await cleanuptask(link);
212 | return;
213 | }
214 | let detailLink = resp.split('\n').filter(function (d) {
215 | return d[0] != '#' && d.trim() != '';
216 | })[0];
217 |
218 | let response2 = await fetch(detailLink);
219 |
220 | let resp2 = await response2.text();
221 | links = resp2.split('\n').filter(function (d) {
222 | return d[0] != '#' && d.trim() != '';
223 | });
224 |
225 | }
226 |
227 | // Map limit solutions:
228 | // https://stackoverflow.com/a/60622224 interesting solution but no error handle in the middle and not await-able
229 |
230 | let keyList = null; // links.map((v, idx, _) => filename + '_' + idx);
231 |
232 | try {
233 | keyList = await worker(links, async (url, idx) => {
234 | let resp = await fetch(url, { credentials: "omit" });
235 | let data = await resp.arrayBuffer();
236 |
237 | let storekey = filename + '_' + idx;
238 | let audio_string = ab2str(data, parseAAC(data)[0]); //timestamp not used for now.
239 | let storage_set = {};
240 | storage_set[storekey] = audio_string;
241 |
242 | await chrome.storage.local.set(storage_set);
243 | return storekey;
244 | }, 6);
245 |
246 | let data = await chrome.storage.local.get(keyList);
247 | let audio_buf = keyList.map(function (x) {
248 | return str2ab(data[x]);
249 | })
250 | let audiodata = new Blob(audio_buf, {
251 | type: "audio/aac"
252 | });
253 |
254 | // Blame on MV3 and service worker.
255 | // Must create blob url in offscreen docuemnt.
256 | let audiourl = await getBlobUrl(audiodata);
257 | chrome.downloads.onChanged.addListener(async function handler(delta) {
258 | if (delta.id == downloadId && delta.state && delta.state.current === "complete") {
259 | chrome.downloads.onChanged.removeListener(handler);
260 | await chrome.storage.local.remove(keyList);
261 |
262 | await revokeBlobUrl(audiourl);
263 | }
264 | });
265 | let downloadId = await chrome.downloads.download({
266 | url: audiourl,
267 | filename: filename
268 | });
269 |
270 | } catch ({ name, message }) {
271 | console.warn(name, message);
272 | await chrome.storage.local.remove(keyList.filter(function (val) { return !val }));
273 | } finally {
274 | await cleanuptask(link);
275 | }
276 | }
--------------------------------------------------------------------------------
/modules/util.js:
--------------------------------------------------------------------------------
1 | import { areaList } from "./constants.js"
2 | import { coordinates, VERSION_MAP, MODEL_LIST, APP_VERSION_MAP } from "./static.js"
3 |
4 | export function genRandomInfo() {
5 | let version = Object.keys(VERSION_MAP)[(Math.floor(Math.random() * Object.keys(VERSION_MAP).length)) >> 0];
6 | let sdk = VERSION_MAP[version].sdk;
7 | let build = VERSION_MAP[version].builds[(Math.floor(Math.random() * VERSION_MAP[version].builds.length)) >> 0];
8 | //Dalvik/2.1.0 (Linux; U; Android %VERSION%; %MODEL%/%BUILD%)
9 | //X-Radiko-Device: %SDKVERSION%.%NORMALIZEMODEL%
10 | let model = MODEL_LIST[(Math.floor(Math.random() * MODEL_LIST.length)) >> 0];
11 | let device = sdk + "." + model;
12 | let useragent = "Dalvik/2.1.0 (Linux; U; Android " + version + "; " + model + "/" + build + ")";
13 |
14 | let appversion = Object.keys(APP_VERSION_MAP)[(Math.floor(Math.random() * Object.keys(APP_VERSION_MAP).length)) >> 0];
15 |
16 | let userid = function () {
17 | let hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
18 | let s = '';
19 | for (let i = 0; i < 32; i++) {
20 | s += hex[(Math.floor(Math.random() * hex.length)) >> 0];
21 | }
22 | return s;
23 | }();
24 |
25 | return {
26 | appversion: appversion,
27 | userid: userid,
28 | useragent: useragent,
29 | device: device
30 | }
31 | }
32 |
33 | export function genGPS(area_id) {
34 | let [lat, long] = coordinates[areaList[parseInt(area_id.substr(2)) - 1]];
35 | // +/- 0 ~ 0.025 --> 0 ~ 1.5' -> +/- 0 ~ 2.77/2.13km
36 | lat = lat + Math.random() / 40.0 * (Math.random() > 0.5 ? 1 : -1);
37 | long = long + Math.random() / 40.0 * (Math.random() > 0.5 ? 1 : -1);
38 | return lat.toFixed(6) + "," + long.toFixed(6) + ",gps";
39 | }
40 |
41 | function fromLong(ipl) {
42 | return ((ipl >>> 24) + '.' + (ipl >> 16 & 255) + '.' + (ipl >> 8 & 255) + '.' + (ipl & 255));
43 | };
44 |
45 | function toLong(ip) {
46 | var ipl = 0;
47 | ip.split('.').forEach(octet => {
48 | ipl <<= 8;
49 | ipl += parseInt(octet);
50 | });
51 | return (ipl >>> 0);
52 | };
53 |
54 |
55 | export function genRandomIp(cidr_array) {
56 | let selectedCIDR = cidr_array[(Math.floor(Math.random() * cidr_array.length)) >> 0];
57 | let tmp = selectedCIDR.split('/');
58 | let addr = tmp[0];
59 | let maskLength = parseInt(tmp[1], 10);
60 | let maskLong = (0xffffffff << (32 - maskLength)) >>> 0
61 | let numberOfAddresses = Math.pow(2, 32 - maskLength);
62 | let first = (toLong(addr) & maskLong) >>> 0;
63 | let pick = (Math.random() * numberOfAddresses) >>> 0;
64 | return fromLong(first + pick);
65 | }
66 |
67 | export function initiatorFromExtension(r) {
68 | let ifInit = r.initiator && r.initiator.toLowerCase().indexOf("chrome-extension://" + chrome.runtime.id) != -1; //initiator since chrome 63
69 | let ifTabId = r.tabId && r.tabId == -1; //mean this resp is not from tab
70 | if (ifInit || ifTabId) {
71 | return true;
72 | }
73 | return false;
74 | }
75 |
76 | // aac parse stuff
77 | // parse hls packed audio (id3 tags and data)
78 | // return id3 tag size and timestamp of this packed audio if success else return [0,0]
79 | export function parseAAC(data) { //data -> Arraybuffer
80 | let processing = new DataView(data);
81 | if (processing.getUint8(0) != 73 || processing.getUint8(1) != 68 || processing.getUint8(2) != 51) { // ID3
82 | return [0, 0];
83 | }
84 | let id3payloadsize = processing.getUint32(6, false); //bigendian
85 | let id3tagsize = 10 + id3payloadsize; //header size + payloadsize
86 |
87 | let timestampLow = processing.getUint32(id3tagsize - 4, false); // 32bit
88 | let timestampHigh = processing.getUint32(id3tagsize - 8, false); //need only the last bit
89 | let timestamp = timestampLow + 0xffffffff * timestampHigh;
90 | return [id3tagsize, timestamp];
91 | }
92 |
93 | export function isFirefox() {
94 | return globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.id;
95 | }
96 | // TODO split to Firefox and Chrome version
97 | export let ab2str;
98 | export let str2ab;
99 |
100 | if (!isFirefox()) { //see webextension-polyfill
101 | // for Chrome
102 | //see chroumium bug: https://bugs.chromium.org/p/chromium/issues/detail?id=831062
103 | ab2str = function ab2str(buf, offset) {
104 | return String.fromCharCode.apply(null, new Uint8Array(buf, offset)); //uint16 will raise must multiple of 2 error
105 | }
106 | str2ab = function str2ab(str) {
107 | let buf = new ArrayBuffer(str.length); // Uint16 -> 2 bytes for each char *2
108 | let bufView = new Uint8Array(buf); // U16
109 |
110 | for (let i = 0, strLen = str.length; i < strLen; i++) {
111 | bufView[i] = str.charCodeAt(i);
112 | }
113 | return buf;
114 | }
115 | } else {
116 | //for Firefox save memory via Uint16Array , use pkcs5 for padding.
117 | ab2str = function ab2str(buf, offset) {
118 | let len = buf.byteLength - offset;
119 | let padding = len % 8 == 0 ? 8 : len % 8;
120 | let crafted = new Uint8Array(len + padding);
121 | let p = new Array(padding);
122 | for (let i = 0; i < padding; i++) {
123 | p[i] = padding;
124 | }
125 | crafted.set(new Uint8Array(buf, offset), 0);
126 | crafted.set(p, len);
127 | return String.fromCharCode.apply(null, new Uint16Array(crafted.buffer));
128 | }
129 | str2ab = function str2ab(str) {
130 | let buf = new ArrayBuffer(str.length * 2);
131 | let bufView = new Uint16Array(buf);
132 | let paddingView = new Uint8Array(buf);
133 | for (let i = 0, strLen = str.length; i < strLen; i++) {
134 | bufView[i] = str.charCodeAt(i);
135 | }
136 | let padding = paddingView[str.length * 2 - 1];
137 | return buf.slice(0, str.length * 2 - padding);
138 | }
139 |
140 | //polyfill
141 | //see https://github.com/kiefferbp/webext-getBytesInUse-polyfill/blob/master/index.js
142 | //Poor performance!!
143 | chrome.storage.local.getBytesInUse = function (keys, callback) {
144 | let size = 0;
145 | if (typeof keys === 'string') {
146 | keys = [keys];
147 | }
148 | chrome.storage.local.get(keys, function (results) {
149 | let lastError = chrome.runtime.lastError;
150 | if (lastError) {
151 | callback(-1);
152 | return;
153 | }
154 | Object.keys(results).forEach(function (key) {
155 | size += (key + JSON.stringify(results[key])).length;
156 | });
157 | callback(size);
158 | });
159 | }
160 | }
161 |
162 |
163 | // https://stackoverflow.com/questions/75527465/download-a-webpage-completely-chrome-extension-manifest-v3/75539867#75539867
164 | export async function getBlobUrl(blob) {
165 | if (!isFirefox()) {
166 | const url = chrome.runtime.getURL('pages/offscreen.html');
167 | try {
168 | await chrome.offscreen.createDocument({
169 | url,
170 | reasons: ['BLOBS'],
171 | justification: 'MV3 requirement',
172 | });
173 | } catch (err) {
174 | if (!err.message.startsWith('Only a single offscreen')) throw err;
175 | }
176 | const client = (await clients.matchAll({ includeUncontrolled: true }))
177 | .find(c => c.url === url);
178 | const mc = new MessageChannel();
179 | client.postMessage(blob, [mc.port2]);
180 | const res = await new Promise(cb => (mc.port1.onmessage = cb));
181 | return res.data;
182 | } else {
183 | return URL.createObjectURL(blob);
184 | }
185 | }
186 |
187 | export async function revokeBlobUrl(blob) {
188 | if (!isFirefox()) {
189 | // TODO should we pass message to offscreen to free blob via URL.revokeObjectURL(blob);
190 | // or just closing the document is enough?
191 | await chrome.offscreen.closeDocument();
192 | } else {
193 | URL.revokeObjectURL(blob);
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/package.py:
--------------------------------------------------------------------------------
1 | import json
2 | import sys
3 | import zipfile
4 |
5 | ICON_LIST = [
6 | "Circle-icons-radio-blue-48.png",
7 | "Circle-icons-radio.png",
8 | "Circle-icons-radio-red-48.png",
9 | "Circle-icons-radio-red.svg",
10 | "Circle-icons-radio.svg",
11 | ]
12 | FILE_LIST = [
13 | "manifest.json",
14 | "README.md",
15 | "LICENSE",
16 | "background.js",
17 | "_locales",
18 | "modules",
19 | "pages",
20 | "response",
21 | "ui",
22 | ] + ICON_LIST
23 |
24 | if __name__ == "__main__":
25 | if len(sys.argv) < 2:
26 | print("python manifest.py ")
27 | sys.exit(-1)
28 |
29 | with open("manifest.jsonc.tmpl", "r") as m:
30 | MANIFEST_JSON = m.read()
31 | ## Support comments
32 | MANIFEST_JSON = "\n".join(
33 | l if not l.lstrip().startswith("//") else "" for l in MANIFEST_JSON.split("\n")
34 | )
35 |
36 | manifest = json.loads(MANIFEST_JSON)
37 | ## Do remove instead of add
38 | if sys.argv[1] == "firefox":
39 | platform = "firefox"
40 | del manifest["incognito"]
41 | del manifest["background"]["service_worker"]
42 | manifest["permissions"] = list(
43 | filter(lambda x: x != "offscreen", manifest["permissions"])
44 | )
45 | elif sys.argv[1] == "chrome":
46 | platform = "chrome"
47 | del manifest["background"]["scripts"]
48 | manifest["permissions"] = list(
49 | filter(
50 | lambda x: x not in ["webRequestBlocking", "webRequestFilterResponse"],
51 | manifest["permissions"],
52 | )
53 | )
54 | else:
55 | print("python manifest.py ")
56 | sys.exit(-1)
57 |
58 | ## dump if debug?
59 | with open("manifest.json", "w") as f:
60 | json.dump(manifest, f, indent=2, ensure_ascii=False)
61 |
62 | version = manifest["version"]
63 | name = manifest["name"]
64 |
65 | zipfile.main(["-c", f"""{name}-{version}-{platform}.zip""", *FILE_LIST])
66 |
--------------------------------------------------------------------------------
/pages/offscreen.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/pages/offscreen.js:
--------------------------------------------------------------------------------
1 | navigator.serviceWorker.onmessage = e => {
2 | e.ports[0].postMessage(URL.createObjectURL(e.data));
3 | };
--------------------------------------------------------------------------------
/pages/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Options
8 |
9 |
29 |
30 |
31 |
32 |
33 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/pages/options.js:
--------------------------------------------------------------------------------
1 | import { NHK_PERMISSION, RECOCHOKU_PERMISSION, TVER_PERMISSION } from "../modules/static.js";
2 |
3 | document.addEventListener("DOMContentLoaded", async function () {
4 | let {
5 | nhkradio_bypass: nhkradio_bypass,
6 | recochoku_ua: recochoku_ua,
7 | tver_fix: tver_fix
8 | } = await chrome.storage.local.get({ "nhkradio_bypass": false, "recochoku_ua": false, "tver_fix": false });
9 |
10 | let nhkradio = document.getElementById("nhkradio");
11 | nhkradio.checked = nhkradio_bypass === true;
12 |
13 | nhkradio.onclick = async (data) => {
14 | if (nhkradio.checked) {
15 | try {
16 | let permitted = await chrome.permissions.request(NHK_PERMISSION);
17 | if (!permitted) {
18 | nhkradio.checked = false;
19 | return;
20 | }
21 | } catch {
22 | nhkradio.checked = false;
23 | return;
24 | }
25 | }
26 | await chrome.storage.local.set({ "nhkradio_bypass": nhkradio.checked });
27 | await chrome.runtime.sendMessage({ "update-nhkradio": nhkradio.checked ? "yes" : "no" });
28 | };
29 |
30 | let recochoku = document.getElementById("recochoku");
31 |
32 | recochoku.checked = recochoku_ua === true;
33 |
34 | recochoku.onclick = async (data) => {
35 | if (recochoku.checked) {
36 | try {
37 | let permitted = await chrome.permissions.request(RECOCHOKU_PERMISSION);
38 | if (!permitted) {
39 | recochoku.checked = false;
40 | return;
41 | }
42 | } catch {
43 | recochoku.checked = false;
44 | return;
45 | }
46 | }
47 | await chrome.storage.local.set({ "recochoku_ua": recochoku.checked });
48 | await chrome.runtime.sendMessage({ "update-recochoku": recochoku.checked ? "yes" : "no" });
49 | }
50 |
51 | let tver = document.getElementById("tver");
52 |
53 | tver.checked = tver_fix === true;
54 |
55 | tver.onclick = async (data) => {
56 | if (tver.checked) {
57 | try {
58 | let permitted = await chrome.permissions.request(TVER_PERMISSION);
59 | if (!permitted) {
60 | tver.checked = false;
61 | return;
62 | }
63 | } catch {
64 | tver.checked = false;
65 | return;
66 | }
67 | }
68 | await chrome.storage.local.set({ "tver_fix": tver.checked });
69 | await chrome.runtime.sendMessage({ "update-tver": tver.checked ? "yes" : "no" });
70 | }
71 | });
72 |
--------------------------------------------------------------------------------
/pages/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Document
9 |
10 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/pages/popup.js:
--------------------------------------------------------------------------------
1 | import { regions, areaListParRegion, radioAreaId } from "../modules/constants.js";
2 | import { isFirefox } from "../modules/util.js";
3 |
4 | function loadArea(regionIdx) {
5 | let area_select = document.getElementById("rajiko-area");
6 | while (area_select.lastChild) {
7 | area_select.removeChild(area_select.lastChild);
8 | }
9 | let id = regions[regionIdx].id;
10 | let areas = areaListParRegion[id];
11 | for (let i = 0; i < areas.length; i++) {
12 | let tmp = document.createElement("option");
13 | tmp.setAttribute("id", areas[i].id);
14 | tmp.innerText = areas[i].name;
15 | area_select.appendChild(tmp);
16 | }
17 | }
18 |
19 | // Looks not necessary for `tmpUrl`, timeshift only change `url`
20 | function stripM3u8link(link) {
21 | let m3u8link = new URL(link);
22 | m3u8link.searchParams.delete("seek");
23 | return m3u8link.toString();
24 | }
25 |
26 |
27 | document.addEventListener("DOMContentLoaded", async function () {
28 | //define event
29 | let region_select = document.getElementById("rajiko-region");
30 | region_select.onchange = function (data) {
31 | loadArea(this.selectedIndex);
32 | };
33 |
34 | let area_select = document.getElementById("rajiko-area");
35 |
36 | let confirm_button = document.getElementById("rajiko-confirm");
37 | confirm_button.innerText = chrome.i18n.getMessage("confirm_button");
38 |
39 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid");
40 |
41 | confirm_button.onclick = async function (data) {
42 | let area = document.getElementById("rajiko-area");
43 | if (area_id && area_id == area) {
44 | //same area;
45 | window.close();
46 | } else {
47 | // Wake up service worker? Is this a bug?
48 | // await chrome.runtime.sendMessage({});
49 | // Send command
50 | await chrome.runtime.sendMessage({ "update-area": area.selectedOptions[0].id });
51 | chrome.tabs.query({ active: true, currentWindow: true }, function (arrayOfTabs) {
52 | if (!arrayOfTabs || arrayOfTabs.length < 1) { return }
53 | let tab = arrayOfTabs[0];
54 | if (/radiko\.jp/.test(tab.url)) {
55 | chrome.tabs.reload(tab.id);
56 | }
57 | // in Windows ,chrome display extension's alert in it's own popup window however size is too small for alert window to close.
58 | window.close();
59 | });
60 | }
61 | };
62 |
63 | if (!area_id) { area_id = "JP13"; }
64 |
65 | for (let i = 0; i < regions.length; i++) {
66 | let tmp = document.createElement("option");
67 | tmp.setAttribute("id", regions[i].id);
68 | tmp.innerText = regions[i].name;
69 | region_select.appendChild(tmp);
70 | }
71 |
72 | Object.keys(areaListParRegion).forEach(function (key, keyindex) {
73 | for (let i = 0; i < areaListParRegion[key].length; i++) {
74 | if (areaListParRegion[key][i].id == area_id) {
75 | region_select.selectedIndex = keyindex
76 | loadArea(keyindex);
77 | area_select.selectedIndex = i;
78 | }
79 | }
80 | });
81 |
82 | let download_button = document.getElementById("rajiko-download");
83 | let record_button = document.getElementById("rajiko-record");
84 |
85 | let { timeshift_list: timeshift_list } = await chrome.storage.local.get({ "timeshift_list": [] });
86 | let { current_recording: current_recording } = await chrome.storage.session.get("current_recording");
87 |
88 | if (current_recording) {
89 | record_button.hidden = false;
90 | record_button.innerText = chrome.i18n.getMessage("record_button_to_stop");
91 | record_button.onclick = async function (data) {
92 | await chrome.runtime.sendMessage({ "stop-recording": true });
93 | window.close();
94 | }
95 | }
96 |
97 | let tabs = await chrome.tabs.query({
98 | active: true,
99 | currentWindow: true,
100 | url: "*://radiko.jp/*"
101 | });
102 | if (tabs && tabs.length >= 1 && tabs[0]) {
103 | let [tab] = tabs;
104 | let inject_results = await chrome.scripting.executeScript({
105 | target: { tabId: tab.id },
106 | func: () => {
107 | let result = {
108 | hasTimeFreePlusAuthority: $.Radiko.login_status.timefreeplus,
109 | tmpUrl: document.getElementById('tmpUrl') && document.getElementById('tmpUrl').value,
110 | url: document.getElementById('url') && document.getElementById('url').value
111 | }
112 | try {
113 | if (result.tmpUrl && result.tmpUrl[0] != "#") {
114 | let ft = (new URL(result.tmpUrl)).searchParams.get("ft")
115 | if (ft) {
116 | let businessDate = window.Radiko.Date.calcBusinessDate(moment(ft, 'YYYYMMDDHHmmss').toDate());
117 | result.needsTimeFreePlusAuthority = window.Radiko.Utility.needsTimeFreePlusAuthority(businessDate);
118 | }
119 | }
120 | } catch { };
121 | return result;
122 | },
123 | // to access $
124 | world: "MAIN"
125 | });
126 | if (inject_results && inject_results.length >= 1 && inject_results[0] && !chrome.runtime.lastError) {
127 | let { result } = inject_results[0];
128 | let href = tab.url;
129 | let url = result && result.url || ''; // #RADIO or http://m3u8list
130 | let tmpUrl = result && result.tmpUrl || ''; // #RADIO or http://m3u8list
131 | let needsTimeFreePlusAuthority = result && result.needsTimeFreePlusAuthority || false;
132 | let hasTimeFreePlusAuthority = result && result.hasTimeFreePlusAuthority || false;
133 |
134 | // only show live recoding when current no work.
135 | if (!current_recording) {
136 | if (url[0] == '#') {
137 | //playing live
138 | record_button.hidden = false;
139 | record_button.innerText = chrome.i18n.getMessage("record_button_to_start", radioAreaId[url.slice(1)].name);
140 | record_button.onclick = async function (data) {
141 | await chrome.runtime.sendMessage({ "start-recording": url.slice(1), "tabId": tab.id });
142 | window.close();
143 | }
144 | } else if (tmpUrl[0] == '#' && /\/live\//.test(href)) {
145 | //viewing live
146 | //Should care about service worker's lifecycle.
147 | record_button.hidden = false;
148 | record_button.innerText = chrome.i18n.getMessage("record_button_to_prepare", radioAreaId[tmpUrl.slice(1)].name);
149 | record_button.onclick = async function (data) {
150 | await chrome.runtime.sendMessage({ "start-recording": tmpUrl.slice(1), "tabId": tab.id });
151 | window.close();
152 | }
153 | }
154 | }
155 | if (tmpUrl.indexOf("https://radiko.jp/v2/api/ts/playlist.m3u8") != -1 && /\/ts\//.test(href)) {
156 | let stripedLink = stripM3u8link(tmpUrl);
157 | if (!timeshift_list.includes(stripedLink)) {
158 | //viewing timeshift
159 | download_button.hidden = false;
160 | download_button.innerText = chrome.i18n.getMessage("timeshift_button");
161 | download_button.onclick = async function () {
162 | await chrome.runtime.sendMessage({ "download-timeshift": { link: stripedLink, tf30: needsTimeFreePlusAuthority } });
163 | window.close();
164 | }
165 |
166 | if (isFirefox() && chrome.extension.inIncognitoContext && hasTimeFreePlusAuthority && needsTimeFreePlusAuthority) {
167 | let hint = document.getElementById("firefox-tf30-incognito");
168 | hint.textContent = chrome.i18n.getMessage("firefox_tf30_incognito");
169 | hint.hidden = false;
170 | }
171 | }
172 | }
173 | }
174 | }
175 | });
176 |
177 |
178 |
179 |
--------------------------------------------------------------------------------
/response/area-JP1.html:
--------------------------------------------------------------------------------
1 | document.write('HOKKAIDO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP10.html:
--------------------------------------------------------------------------------
1 | document.write('GUNMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP11.html:
--------------------------------------------------------------------------------
1 | document.write('SAITAMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP12.html:
--------------------------------------------------------------------------------
1 | document.write('CHIBA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP13.html:
--------------------------------------------------------------------------------
1 | document.write('TOKYO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP14.html:
--------------------------------------------------------------------------------
1 | document.write('KANAGAWA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP15.html:
--------------------------------------------------------------------------------
1 | document.write('NIIGATA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP16.html:
--------------------------------------------------------------------------------
1 | document.write('TOYAMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP17.html:
--------------------------------------------------------------------------------
1 | document.write('ISHIKAWA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP18.html:
--------------------------------------------------------------------------------
1 | document.write('FUKUI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP19.html:
--------------------------------------------------------------------------------
1 | document.write('YAMANASHI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP2.html:
--------------------------------------------------------------------------------
1 | document.write('AOMORI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP20.html:
--------------------------------------------------------------------------------
1 | document.write('NAGANO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP21.html:
--------------------------------------------------------------------------------
1 | document.write('GIFU JAPAN');
--------------------------------------------------------------------------------
/response/area-JP22.html:
--------------------------------------------------------------------------------
1 | document.write('SHIZUOKA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP23.html:
--------------------------------------------------------------------------------
1 | document.write('AICHI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP24.html:
--------------------------------------------------------------------------------
1 | document.write('MIE JAPAN');
--------------------------------------------------------------------------------
/response/area-JP25.html:
--------------------------------------------------------------------------------
1 | document.write('SHIGA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP26.html:
--------------------------------------------------------------------------------
1 | document.write('KYOTO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP27.html:
--------------------------------------------------------------------------------
1 | document.write('OSAKA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP28.html:
--------------------------------------------------------------------------------
1 | document.write('HYOGO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP29.html:
--------------------------------------------------------------------------------
1 | document.write('NARA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP3.html:
--------------------------------------------------------------------------------
1 | document.write('IWATE JAPAN');
--------------------------------------------------------------------------------
/response/area-JP30.html:
--------------------------------------------------------------------------------
1 | document.write('WAKAYAMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP31.html:
--------------------------------------------------------------------------------
1 | document.write('TOTTORI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP32.html:
--------------------------------------------------------------------------------
1 | document.write('SHIMANE JAPAN');
--------------------------------------------------------------------------------
/response/area-JP33.html:
--------------------------------------------------------------------------------
1 | document.write('OKAYAMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP34.html:
--------------------------------------------------------------------------------
1 | document.write('HIROSHIMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP35.html:
--------------------------------------------------------------------------------
1 | document.write('YAMAGUCHI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP36.html:
--------------------------------------------------------------------------------
1 | document.write('TOKUSHIMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP37.html:
--------------------------------------------------------------------------------
1 | document.write('KAGAWA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP38.html:
--------------------------------------------------------------------------------
1 | document.write('EHIME JAPAN');
--------------------------------------------------------------------------------
/response/area-JP39.html:
--------------------------------------------------------------------------------
1 | document.write('KOCHI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP4.html:
--------------------------------------------------------------------------------
1 | document.write('MIYAGI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP40.html:
--------------------------------------------------------------------------------
1 | document.write('FUKUOKA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP41.html:
--------------------------------------------------------------------------------
1 | document.write('SAGA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP42.html:
--------------------------------------------------------------------------------
1 | document.write('NAGASAKI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP43.html:
--------------------------------------------------------------------------------
1 | document.write('KUMAMOTO JAPAN');
--------------------------------------------------------------------------------
/response/area-JP44.html:
--------------------------------------------------------------------------------
1 | document.write('OITA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP45.html:
--------------------------------------------------------------------------------
1 | document.write('MIYAZAKI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP46.html:
--------------------------------------------------------------------------------
1 | document.write('KAGOSHIMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP47.html:
--------------------------------------------------------------------------------
1 | document.write('OKINAWA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP5.html:
--------------------------------------------------------------------------------
1 | document.write('AKITA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP6.html:
--------------------------------------------------------------------------------
1 | document.write('YAMAGATA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP7.html:
--------------------------------------------------------------------------------
1 | document.write('FUKUSHIMA JAPAN');
--------------------------------------------------------------------------------
/response/area-JP8.html:
--------------------------------------------------------------------------------
1 | document.write('IBARAKI JAPAN');
--------------------------------------------------------------------------------
/response/area-JP9.html:
--------------------------------------------------------------------------------
1 | document.write('TOCHIGI JAPAN');
--------------------------------------------------------------------------------
/response/auth2-JP1.html:
--------------------------------------------------------------------------------
1 | JP1,北海道,hokkaido Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP10.html:
--------------------------------------------------------------------------------
1 | JP10,群馬県,gunma Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP11.html:
--------------------------------------------------------------------------------
1 | JP11,埼玉県,saitama Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP12.html:
--------------------------------------------------------------------------------
1 | JP12,千葉県,chiba Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP13.html:
--------------------------------------------------------------------------------
1 | JP13,東京都,tokyo Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP14.html:
--------------------------------------------------------------------------------
1 | JP14,神奈川県,kanagawa Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP15.html:
--------------------------------------------------------------------------------
1 | JP15,新潟県,niigata Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP16.html:
--------------------------------------------------------------------------------
1 | JP16,富山県,toyama Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP17.html:
--------------------------------------------------------------------------------
1 | JP17,石川県,ishikawa Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP18.html:
--------------------------------------------------------------------------------
1 | JP18,福井県,fukui Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP19.html:
--------------------------------------------------------------------------------
1 | JP19,山梨県,yamanashi Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP2.html:
--------------------------------------------------------------------------------
1 | JP2,青森県,aomori Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP20.html:
--------------------------------------------------------------------------------
1 | JP20,長野県,nagano Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP21.html:
--------------------------------------------------------------------------------
1 | JP21,岐阜県,gifu Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP22.html:
--------------------------------------------------------------------------------
1 | JP22,静岡県,shizuoka Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP23.html:
--------------------------------------------------------------------------------
1 | JP23,愛知県,aichi Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP24.html:
--------------------------------------------------------------------------------
1 | JP24,三重県,mie Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP25.html:
--------------------------------------------------------------------------------
1 | JP25,滋賀県,shiga Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP26.html:
--------------------------------------------------------------------------------
1 | JP26,京都府,kyoto Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP27.html:
--------------------------------------------------------------------------------
1 | JP27,大阪府,osaka Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP28.html:
--------------------------------------------------------------------------------
1 | JP28,兵庫県,hyogo Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP29.html:
--------------------------------------------------------------------------------
1 | JP29,奈良県,nara Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP3.html:
--------------------------------------------------------------------------------
1 | JP3,岩手県,iwate Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP30.html:
--------------------------------------------------------------------------------
1 | JP30,和歌山県,wakayama Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP31.html:
--------------------------------------------------------------------------------
1 | JP31,鳥取県,tottori Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP32.html:
--------------------------------------------------------------------------------
1 | JP32,島根県,shimane Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP33.html:
--------------------------------------------------------------------------------
1 | JP33,岡山県,okayama Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP34.html:
--------------------------------------------------------------------------------
1 | JP34,広島県,hiroshima Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP35.html:
--------------------------------------------------------------------------------
1 | JP35,山口県,yamaguchi Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP36.html:
--------------------------------------------------------------------------------
1 | JP36,徳島県,tokushima Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP37.html:
--------------------------------------------------------------------------------
1 | JP37,香川県,kagawa Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP38.html:
--------------------------------------------------------------------------------
1 | JP38,愛媛県,ehime Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP39.html:
--------------------------------------------------------------------------------
1 | JP39,高知県,kouchi Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP4.html:
--------------------------------------------------------------------------------
1 | JP4,宮城県,miyagi Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP40.html:
--------------------------------------------------------------------------------
1 | JP40,福岡県,fukuoka Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP41.html:
--------------------------------------------------------------------------------
1 | JP41,佐賀県,saga Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP42.html:
--------------------------------------------------------------------------------
1 | JP42,長崎県,nagasaki Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP43.html:
--------------------------------------------------------------------------------
1 | JP43,熊本県,kumamoto Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP44.html:
--------------------------------------------------------------------------------
1 | JP44,大分県,oita Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP45.html:
--------------------------------------------------------------------------------
1 | JP45,宮崎県,miyazaki Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP46.html:
--------------------------------------------------------------------------------
1 | JP46,鹿児島県,kagoshima Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP47.html:
--------------------------------------------------------------------------------
1 | JP47,沖縄県,okinawa Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP5.html:
--------------------------------------------------------------------------------
1 | JP5,秋田県,akita Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP6.html:
--------------------------------------------------------------------------------
1 | JP6,山形県,yamagata Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP7.html:
--------------------------------------------------------------------------------
1 | JP7,福島県,fukushima Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP8.html:
--------------------------------------------------------------------------------
1 | JP8,茨城県,ibaraki Japan
2 |
--------------------------------------------------------------------------------
/response/auth2-JP9.html:
--------------------------------------------------------------------------------
1 | JP9,栃木県,tochigi Japan
2 |
--------------------------------------------------------------------------------
/ui/common_start.js:
--------------------------------------------------------------------------------
1 | document.addEventListener("DOMContentLoaded", function (event) {
2 | let inspect_script = document.createElement("script");
3 | inspect_script.src = chrome.runtime.getURL('ui/inspect_start.js');
4 | document.head.appendChild(inspect_script);
5 |
6 | // Implement stop recording by watching the play button's attribution.
7 | // TODO what about the big saisei button?
8 | let targetPlayButton = document.getElementById('play').getElementsByTagName('i')[0];
9 | let observer = new MutationObserver(function (list) {
10 | for (let mutation of list) {
11 | if (mutation.type == 'attributes' && mutation.attributeName == 'class' && !mutation.target.classList.contains('on')) {
12 | chrome.runtime.sendMessage({ "stop-recording": true });
13 | }
14 | }
15 | });
16 | observer.observe(targetPlayButton, { attributes: true });
17 | });
--------------------------------------------------------------------------------
/ui/inspect_start.js:
--------------------------------------------------------------------------------
1 | const needChangeTZ = Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase() != 'asia/tokyo';
2 | if (needChangeTZ) {
3 | //for those who are from different timezones.
4 | //TODO: better solution? such as changing Date prototype?
5 |
6 | //! moment-timezone.js
7 | //! version : 0.5.14
8 | //! Copyright (c) JS Foundation and other contributors
9 | //! license : MIT
10 | //! github.com/moment/moment-timezone
11 | (function (root, factory) {
12 | "use strict";
13 |
14 | /*global define*/
15 | if (typeof define === 'function' && define.amd) {
16 | define(['moment'], factory); // AMD
17 | } else if (typeof module === 'object' && module.exports) {
18 | module.exports = factory(require('moment')); // Node
19 | } else {
20 | factory(root.moment); // Browser
21 | }
22 |
23 | }(this, function (moment) {
24 | "use strict";
25 |
26 | // Do not load moment-timezone a second time.
27 | // if (moment.tz !== undefined) {
28 | // logError('Moment Timezone ' + moment.tz.version + ' was already loaded ' + (moment.tz.dataVersion ? 'with data from ' : 'without any data') + moment.tz.dataVersion);
29 | // return moment;
30 | // }
31 |
32 | var VERSION = "0.5.14",
33 | zones = {},
34 | links = {},
35 | names = {},
36 | guesses = {},
37 | cachedGuess,
38 |
39 | momentVersion = moment.version.split('.'),
40 | major = +momentVersion[0],
41 | minor = +momentVersion[1];
42 |
43 | // Moment.js version check
44 | if (major < 2 || (major === 2 && minor < 6)) {
45 | logError('Moment Timezone requires Moment.js >= 2.6.0. You are using Moment.js ' + moment.version + '. See momentjs.com');
46 | }
47 |
48 | /************************************
49 | Unpacking
50 | ************************************/
51 |
52 | function charCodeToInt(charCode) {
53 | if (charCode > 96) {
54 | return charCode - 87;
55 | } else if (charCode > 64) {
56 | return charCode - 29;
57 | }
58 | return charCode - 48;
59 | }
60 |
61 | function unpackBase60(string) {
62 | var i = 0,
63 | parts = string.split('.'),
64 | whole = parts[0],
65 | fractional = parts[1] || '',
66 | multiplier = 1,
67 | num,
68 | out = 0,
69 | sign = 1;
70 |
71 | // handle negative numbers
72 | if (string.charCodeAt(0) === 45) {
73 | i = 1;
74 | sign = -1;
75 | }
76 |
77 | // handle digits before the decimal
78 | for (i; i < whole.length; i++) {
79 | num = charCodeToInt(whole.charCodeAt(i));
80 | out = 60 * out + num;
81 | }
82 |
83 | // handle digits after the decimal
84 | for (i = 0; i < fractional.length; i++) {
85 | multiplier = multiplier / 60;
86 | num = charCodeToInt(fractional.charCodeAt(i));
87 | out += num * multiplier;
88 | }
89 |
90 | return out * sign;
91 | }
92 |
93 | function arrayToInt(array) {
94 | for (var i = 0; i < array.length; i++) {
95 | array[i] = unpackBase60(array[i]);
96 | }
97 | }
98 |
99 | function intToUntil(array, length) {
100 | for (var i = 0; i < length; i++) {
101 | array[i] = Math.round((array[i - 1] || 0) + (array[i] * 60000)); // minutes to milliseconds
102 | }
103 |
104 | array[length - 1] = Infinity;
105 | }
106 |
107 | function mapIndices(source, indices) {
108 | var out = [], i;
109 |
110 | for (i = 0; i < indices.length; i++) {
111 | out[i] = source[indices[i]];
112 | }
113 |
114 | return out;
115 | }
116 |
117 | function unpack(string) {
118 | var data = string.split('|'),
119 | offsets = data[2].split(' '),
120 | indices = data[3].split(''),
121 | untils = data[4].split(' ');
122 |
123 | arrayToInt(offsets);
124 | arrayToInt(indices);
125 | arrayToInt(untils);
126 |
127 | intToUntil(untils, indices.length);
128 |
129 | return {
130 | name: data[0],
131 | abbrs: mapIndices(data[1].split(' '), indices),
132 | offsets: mapIndices(offsets, indices),
133 | untils: untils,
134 | population: data[5] | 0
135 | };
136 | }
137 |
138 | /************************************
139 | Zone object
140 | ************************************/
141 |
142 | function Zone(packedString) {
143 | if (packedString) {
144 | this._set(unpack(packedString));
145 | }
146 | }
147 |
148 | Zone.prototype = {
149 | _set: function (unpacked) {
150 | this.name = unpacked.name;
151 | this.abbrs = unpacked.abbrs;
152 | this.untils = unpacked.untils;
153 | this.offsets = unpacked.offsets;
154 | this.population = unpacked.population;
155 | },
156 |
157 | _index: function (timestamp) {
158 | var target = +timestamp,
159 | untils = this.untils,
160 | i;
161 |
162 | for (i = 0; i < untils.length; i++) {
163 | if (target < untils[i]) {
164 | return i;
165 | }
166 | }
167 | },
168 |
169 | parse: function (timestamp) {
170 | var target = +timestamp,
171 | offsets = this.offsets,
172 | untils = this.untils,
173 | max = untils.length - 1,
174 | offset, offsetNext, offsetPrev, i;
175 |
176 | for (i = 0; i < max; i++) {
177 | offset = offsets[i];
178 | offsetNext = offsets[i + 1];
179 | offsetPrev = offsets[i ? i - 1 : i];
180 |
181 | if (offset < offsetNext && tz.moveAmbiguousForward) {
182 | offset = offsetNext;
183 | } else if (offset > offsetPrev && tz.moveInvalidForward) {
184 | offset = offsetPrev;
185 | }
186 |
187 | if (target < untils[i] - (offset * 60000)) {
188 | return offsets[i];
189 | }
190 | }
191 |
192 | return offsets[max];
193 | },
194 |
195 | abbr: function (mom) {
196 | return this.abbrs[this._index(mom)];
197 | },
198 |
199 | offset: function (mom) {
200 | logError("zone.offset has been deprecated in favor of zone.utcOffset");
201 | return this.offsets[this._index(mom)];
202 | },
203 |
204 | utcOffset: function (mom) {
205 | return this.offsets[this._index(mom)];
206 | }
207 | };
208 |
209 | /************************************
210 | Current Timezone
211 | ************************************/
212 |
213 | function OffsetAt(at) {
214 | var timeString = at.toTimeString();
215 | var abbr = timeString.match(/\([a-z ]+\)/i);
216 | if (abbr && abbr[0]) {
217 | // 17:56:31 GMT-0600 (CST)
218 | // 17:56:31 GMT-0600 (Central Standard Time)
219 | abbr = abbr[0].match(/[A-Z]/g);
220 | abbr = abbr ? abbr.join('') : undefined;
221 | } else {
222 | // 17:56:31 CST
223 | // 17:56:31 GMT+0800 (台北標準時間)
224 | abbr = timeString.match(/[A-Z]{3,5}/g);
225 | abbr = abbr ? abbr[0] : undefined;
226 | }
227 |
228 | if (abbr === 'GMT') {
229 | abbr = undefined;
230 | }
231 |
232 | this.at = +at;
233 | this.abbr = abbr;
234 | this.offset = at.getTimezoneOffset();
235 | }
236 |
237 | function ZoneScore(zone) {
238 | this.zone = zone;
239 | this.offsetScore = 0;
240 | this.abbrScore = 0;
241 | }
242 |
243 | ZoneScore.prototype.scoreOffsetAt = function (offsetAt) {
244 | this.offsetScore += Math.abs(this.zone.utcOffset(offsetAt.at) - offsetAt.offset);
245 | if (this.zone.abbr(offsetAt.at).replace(/[^A-Z]/g, '') !== offsetAt.abbr) {
246 | this.abbrScore++;
247 | }
248 | };
249 |
250 | function findChange(low, high) {
251 | var mid, diff;
252 |
253 | while ((diff = ((high.at - low.at) / 12e4 | 0) * 6e4)) {
254 | mid = new OffsetAt(new Date(low.at + diff));
255 | if (mid.offset === low.offset) {
256 | low = mid;
257 | } else {
258 | high = mid;
259 | }
260 | }
261 |
262 | return low;
263 | }
264 |
265 | function userOffsets() {
266 | var startYear = new Date().getFullYear() - 2,
267 | last = new OffsetAt(new Date(startYear, 0, 1)),
268 | offsets = [last],
269 | change, next, i;
270 |
271 | for (i = 1; i < 48; i++) {
272 | next = new OffsetAt(new Date(startYear, i, 1));
273 | if (next.offset !== last.offset) {
274 | change = findChange(last, next);
275 | offsets.push(change);
276 | offsets.push(new OffsetAt(new Date(change.at + 6e4)));
277 | }
278 | last = next;
279 | }
280 |
281 | for (i = 0; i < 4; i++) {
282 | offsets.push(new OffsetAt(new Date(startYear + i, 0, 1)));
283 | offsets.push(new OffsetAt(new Date(startYear + i, 6, 1)));
284 | }
285 |
286 | return offsets;
287 | }
288 |
289 | function sortZoneScores(a, b) {
290 | if (a.offsetScore !== b.offsetScore) {
291 | return a.offsetScore - b.offsetScore;
292 | }
293 | if (a.abbrScore !== b.abbrScore) {
294 | return a.abbrScore - b.abbrScore;
295 | }
296 | return b.zone.population - a.zone.population;
297 | }
298 |
299 | function addToGuesses(name, offsets) {
300 | var i, offset;
301 | arrayToInt(offsets);
302 | for (i = 0; i < offsets.length; i++) {
303 | offset = offsets[i];
304 | guesses[offset] = guesses[offset] || {};
305 | guesses[offset][name] = true;
306 | }
307 | }
308 |
309 | function guessesForUserOffsets(offsets) {
310 | var offsetsLength = offsets.length,
311 | filteredGuesses = {},
312 | out = [],
313 | i, j, guessesOffset;
314 |
315 | for (i = 0; i < offsetsLength; i++) {
316 | guessesOffset = guesses[offsets[i].offset] || {};
317 | for (j in guessesOffset) {
318 | if (guessesOffset.hasOwnProperty(j)) {
319 | filteredGuesses[j] = true;
320 | }
321 | }
322 | }
323 |
324 | for (i in filteredGuesses) {
325 | if (filteredGuesses.hasOwnProperty(i)) {
326 | out.push(names[i]);
327 | }
328 | }
329 |
330 | return out;
331 | }
332 |
333 | function rebuildGuess() {
334 |
335 | // use Intl API when available and returning valid time zone
336 | try {
337 | var intlName = Intl.DateTimeFormat().resolvedOptions().timeZone;
338 | if (intlName && intlName.length > 3) {
339 | var name = names[normalizeName(intlName)];
340 | if (name) {
341 | return name;
342 | }
343 | logError("Moment Timezone found " + intlName + " from the Intl api, but did not have that data loaded.");
344 | }
345 | } catch (e) {
346 | // Intl unavailable, fall back to manual guessing.
347 | }
348 |
349 | var offsets = userOffsets(),
350 | offsetsLength = offsets.length,
351 | guesses = guessesForUserOffsets(offsets),
352 | zoneScores = [],
353 | zoneScore, i, j;
354 |
355 | for (i = 0; i < guesses.length; i++) {
356 | zoneScore = new ZoneScore(getZone(guesses[i]), offsetsLength);
357 | for (j = 0; j < offsetsLength; j++) {
358 | zoneScore.scoreOffsetAt(offsets[j]);
359 | }
360 | zoneScores.push(zoneScore);
361 | }
362 |
363 | zoneScores.sort(sortZoneScores);
364 |
365 | return zoneScores.length > 0 ? zoneScores[0].zone.name : undefined;
366 | }
367 |
368 | function guess(ignoreCache) {
369 | if (!cachedGuess || ignoreCache) {
370 | cachedGuess = rebuildGuess();
371 | }
372 | return cachedGuess;
373 | }
374 |
375 | /************************************
376 | Global Methods
377 | ************************************/
378 |
379 | function normalizeName(name) {
380 | return (name || '').toLowerCase().replace(/\//g, '_');
381 | }
382 |
383 | function addZone(packed) {
384 | var i, name, split, normalized;
385 |
386 | if (typeof packed === "string") {
387 | packed = [packed];
388 | }
389 |
390 | for (i = 0; i < packed.length; i++) {
391 | split = packed[i].split('|');
392 | name = split[0];
393 | normalized = normalizeName(name);
394 | zones[normalized] = packed[i];
395 | names[normalized] = name;
396 | addToGuesses(normalized, split[2].split(' '));
397 | }
398 | }
399 |
400 | function getZone(name, caller) {
401 | name = normalizeName(name);
402 |
403 | var zone = zones[name];
404 | var link;
405 |
406 | if (zone instanceof Zone) {
407 | return zone;
408 | }
409 |
410 | if (typeof zone === 'string') {
411 | zone = new Zone(zone);
412 | zones[name] = zone;
413 | return zone;
414 | }
415 |
416 | // Pass getZone to prevent recursion more than 1 level deep
417 | if (links[name] && caller !== getZone && (link = getZone(links[name], getZone))) {
418 | zone = zones[name] = new Zone();
419 | zone._set(link);
420 | zone.name = names[name];
421 | return zone;
422 | }
423 |
424 | return null;
425 | }
426 |
427 | function getNames() {
428 | var i, out = [];
429 |
430 | for (i in names) {
431 | if (names.hasOwnProperty(i) && (zones[i] || zones[links[i]]) && names[i]) {
432 | out.push(names[i]);
433 | }
434 | }
435 |
436 | return out.sort();
437 | }
438 |
439 | function addLink(aliases) {
440 | var i, alias, normal0, normal1;
441 |
442 | if (typeof aliases === "string") {
443 | aliases = [aliases];
444 | }
445 |
446 | for (i = 0; i < aliases.length; i++) {
447 | alias = aliases[i].split('|');
448 |
449 | normal0 = normalizeName(alias[0]);
450 | normal1 = normalizeName(alias[1]);
451 |
452 | links[normal0] = normal1;
453 | names[normal0] = alias[0];
454 |
455 | links[normal1] = normal0;
456 | names[normal1] = alias[1];
457 | }
458 | }
459 |
460 | function loadData(data) {
461 | addZone(data.zones);
462 | addLink(data.links);
463 | tz.dataVersion = data.version;
464 | }
465 |
466 | function zoneExists(name) {
467 | if (!zoneExists.didShowError) {
468 | zoneExists.didShowError = true;
469 | logError("moment.tz.zoneExists('" + name + "') has been deprecated in favor of !moment.tz.zone('" + name + "')");
470 | }
471 | return !!getZone(name);
472 | }
473 |
474 | function needsOffset(m) {
475 | var isUnixTimestamp = (m._f === 'X' || m._f === 'x');
476 | return !!(m._a && (m._tzm === undefined) && !isUnixTimestamp);
477 | }
478 |
479 | function logError(message) {
480 | if (typeof console !== 'undefined' && typeof console.error === 'function') {
481 | console.error(message);
482 | }
483 | }
484 |
485 | /************************************
486 | moment.tz namespace
487 | ************************************/
488 |
489 | function tz(input) {
490 | var args = Array.prototype.slice.call(arguments, 0, -1),
491 | name = arguments[arguments.length - 1],
492 | zone = getZone(name),
493 | out = moment.utc.apply(null, args);
494 |
495 | if (zone && !moment.isMoment(input) && needsOffset(out)) {
496 | out.add(zone.parse(out), 'minutes');
497 | }
498 |
499 | out.tz(name);
500 |
501 | return out;
502 | }
503 |
504 | tz.version = VERSION;
505 | tz.dataVersion = '';
506 | tz._zones = zones;
507 | tz._links = links;
508 | tz._names = names;
509 | tz.add = addZone;
510 | tz.link = addLink;
511 | tz.load = loadData;
512 | tz.zone = getZone;
513 | tz.zoneExists = zoneExists; // deprecated in 0.1.0
514 | tz.guess = guess;
515 | tz.names = getNames;
516 | tz.Zone = Zone;
517 | tz.unpack = unpack;
518 | tz.unpackBase60 = unpackBase60;
519 | tz.needsOffset = needsOffset;
520 | tz.moveInvalidForward = true;
521 | tz.moveAmbiguousForward = false;
522 |
523 | /************************************
524 | Interface with Moment.js
525 | ************************************/
526 |
527 | var fn = moment.fn;
528 |
529 | moment.tz = tz;
530 |
531 | moment.defaultZone = null;
532 |
533 | moment.updateOffset = function (mom, keepTime) {
534 | var zone = moment.defaultZone,
535 | offset;
536 |
537 | if (mom._z === undefined) {
538 | if (zone && needsOffset(mom) && !mom._isUTC) {
539 | mom._d = moment.utc(mom._a)._d;
540 | mom.utc().add(zone.parse(mom), 'minutes');
541 | }
542 | mom._z = zone;
543 | }
544 | if (mom._z) {
545 | offset = mom._z.utcOffset(mom);
546 | if (Math.abs(offset) < 16) {
547 | offset = offset / 60;
548 | }
549 | if (mom.utcOffset !== undefined) {
550 | mom.utcOffset(-offset, keepTime);
551 | } else {
552 | mom.zone(offset, keepTime);
553 | }
554 | }
555 | };
556 |
557 | fn.tz = function (name, keepTime) {
558 | if (name) {
559 | this._z = getZone(name);
560 | if (this._z) {
561 | moment.updateOffset(this, keepTime);
562 | } else {
563 | logError("Moment Timezone has no data for " + name + ". See http://momentjs.com/timezone/docs/#/data-loading/.");
564 | }
565 | return this;
566 | }
567 | if (this._z) { return this._z.name; }
568 | };
569 |
570 | function abbrWrap(old) {
571 | return function () {
572 | if (this._z) { return this._z.abbr(this); }
573 | return old.call(this);
574 | };
575 | }
576 |
577 | function resetZoneWrap(old) {
578 | return function () {
579 | this._z = null;
580 | return old.apply(this, arguments);
581 | };
582 | }
583 |
584 | fn.zoneName = abbrWrap(fn.zoneName);
585 | fn.zoneAbbr = abbrWrap(fn.zoneAbbr);
586 | fn.utc = resetZoneWrap(fn.utc);
587 |
588 | moment.tz.setDefault = function (name) {
589 | if (major < 2 || (major === 2 && minor < 9)) {
590 | logError('Moment Timezone setDefault() requires Moment.js >= 2.9.0. You are using Moment.js ' + moment.version + '.');
591 | }
592 | moment.defaultZone = name ? getZone(name) : null;
593 | return moment;
594 | };
595 |
596 | // Cloning a moment should include the _z property.
597 | var momentProperties = moment.momentProperties;
598 | if (Object.prototype.toString.call(momentProperties) === '[object Array]') {
599 | // moment 2.8.1+
600 | momentProperties.push('_z');
601 | momentProperties.push('_a');
602 | } else if (momentProperties) {
603 | // moment 2.7.0
604 | momentProperties._z = null;
605 | }
606 |
607 | // INJECT DATA
608 |
609 | return moment;
610 | }));
611 | //! moment-timezone.js end
612 |
613 | moment.tz.add("Asia/Tokyo|JST JDT|-90 -a0|010101010|-QJH0 QL0 1lB0 13X0 1zB0 NX0 1zB0 NX0|38e6");
614 | moment.tz.link("Asia/Tokyo|Japan");
615 | const tokyozone = moment.tz.zone("Asia/Tokyo");
616 | moment.defaultZone = tokyozone;
617 |
618 | // moment.tz.zone('Asia/Tokyo').utcOffset(0)) -> -540
619 | const diffMin = (new Date()).getTimezoneOffset() - moment.tz.zone('Asia/Tokyo').utcOffset(0);
620 | const diffTimestamp = -1 * 60 * 1000 * diffMin; //different direction
621 | const diffSec = diffMin * 60;
622 | const diffHour = diffMin / 60;
623 |
624 | // for timeshift's timetable
625 | let oldsetScrollInit = setScrollInit;
626 | setScrollInit = function () {
627 | let oldGetHours = Date.prototype.getHours;
628 | Date.prototype.getHours = function () { return (24 + oldGetHours.bind(this)() + Math.floor(diffHour)) % 24; };
629 | oldsetScrollInit();
630 | Date.prototype.getHours = oldGetHours;
631 | }
632 |
633 | // var oldSetSeekPlayTime = $.Radiko.Player.setSeekPlayTime
634 | // $.Radiko.Player.setSeekPlayTime = function(startSec,endSec) {
635 | // return oldSetSeekPlayTime(startSec+diffSec,endSec-diffSec)}
636 |
637 | // note: this conflicts with setSeekPlayTime's modification
638 | let oldonChangeCurrentTime = $.Radiko.Player.View.seekBarView.__proto__.onChangeCurrentTime;
639 | $.Radiko.Player.View.seekBarView.stopListening($.Radiko.Player.Model, 'change:currentTime');
640 | $.Radiko.Player.View.seekBarView.listenTo($.Radiko.Player.Model, 'change:currentTime', function (model, currentTime) {
641 | // for past timeshift on non-default region -> 0
642 | // other ( ongoing timeshift on default/non-default , past timeshift on default) -> diffSec
643 | return oldonChangeCurrentTime(model, currentTime + (player.chasing() ? diffSec : 0));
644 | })
645 |
646 | //because ftTime is JST time
647 | //apps/js/playerCommon.js?_=20180221
648 |
649 | // this conflicts with newonDragSeek
650 | //var oldupdateBalloon = $.Radiko.Player.View.seekBarView.__proto__.updateBalloon;
651 | //$.Radiko.Player.View.seekBarView.__proto__.updateBalloon = function (ftTime, addTime) {
652 | // oldupdateBalloon(ftTime + diffTimestamp, addTime);
653 | //}
654 |
655 | let oldonDragSeek = $.Radiko.Player.View.seekBarView.__proto__.onDragSeek;
656 | let newonDragSeek = function () { moment.defaultZone = null; oldonDragSeek.call($.Radiko.Player.View.seekBarView); moment.defaultZone = tokyozone; }
657 |
658 | $("#seekbar").find(".knob").draggable("option", { drag: newonDragSeek });
659 | }
660 |
661 |
662 |
663 | //break timeshift 3hour limit
664 | // from tsdetail -> scheduleId (for 1 day , check every 1s ) && storeWatchId (for 3 hours,check on update)
665 | // this watcher is the first.
666 | store.watch('update',
667 | function (key, val, oldVal) { // if oldVal == undefined -> a new created one
668 | if (/[0-9]{14}$/.test(key)) {
669 | val.listened_time = 0;
670 | val.limit = moment(val.to, 'YYYYMMDDHHmmss').unix() + 8 * 24 * 60 * 60; //same as tsdetail.js, but will be delete after 8*2 days
671 | //use raw store api
672 | store.storage.write(key, JSON.stringify(val));
673 | }
674 | }
675 | );
676 | // Bypass question dialog
677 | if (!store.get('rdk_profile_data')) {
678 | store.set('rdk_profile_data', true);
679 | }
680 |
681 |
682 |
683 |
684 | //to bypass check at
685 | // to enfore select stream_smh_multi url areafree = 0 link (bypass containStation check)
686 | // also bypass connectiontype check! see allocateConnection
687 | // to pass our generated token
688 | // this may run after d2-app report premium?
689 | $.Radiko.login_status.areafree = 1;
690 | $.Radiko.login_status.premium = 1;
691 |
692 | window.isStationInArea = function () { return true; }
693 | // `Preroll` is CM/AD related
694 | // See onChunkListLoaded case 'AD-TYPE':
695 | // 0 is for premium, so i think it is no-ad
696 | // 2 maybe has AD
697 | window.getPrerollParam = () => { return '0'; }
698 |
--------------------------------------------------------------------------------
/ui/mobile.css:
--------------------------------------------------------------------------------
1 | body {
2 | width: 100%
3 | }
4 |
5 | /*display: none*/
6 | /*for login button*/
7 | .header__utility {
8 | display: none
9 | }
10 |
11 | /*.header__utility {padding: unset;}
12 | .header__utility .header__inner {height: unset;}
13 | .header__logo {display: none}
14 | .header__menu ul:nth-child(1) {display: none}
15 | .header__banner {display: none}
16 | .header__menu .btn--login {position: fixed; bottom: 75px; z-index: 1000 ;right: 20px;font-size: 0;border-radius: 200px;border: unset;
17 |
18 | height:45px;
19 | width: 45px;
20 | background-color: #f8f8f8;
21 | }
22 | .header__menu .btn--setting .icon, .header__menu .btn--login .icon, .header__menu .btn--logout .icon {
23 | margin-right: 1px;
24 | vertical-align: -17px;
25 | background-position: -314px -371px;
26 | width: 15px;
27 | height: 20px;
28 | }
29 | .header__menu .item--premium {display: none}
30 | */
31 | /*
32 | .icon--login-02 {
33 |
34 |
35 | .icon--visitor {
36 | background-image: url(../images/sprite.png);
37 | background-position: -473px -218px;
38 | width: 30px;
39 | height: 30px; }
40 | .icon -people {
41 | background-position: -221px -126px;
42 | width: 26px;
43 | height: 35px;
44 | }
45 |
46 |
47 | */
48 |
49 |
50 | /**/
51 | .header__nav-container {
52 | height: unset
53 | }
54 |
55 | .header__nav-outer {
56 | position: fixed;
57 | top: 0;
58 | left: 0;
59 | }
60 |
61 | .header__inner {
62 | width: unset
63 | }
64 |
65 | .header .header__nav .item__live {
66 | width: unset
67 | }
68 |
69 | .header .header__nav .item__timeshift {
70 | width: unset
71 | }
72 |
73 | .header .header__nav .item__areafree {
74 | width: unset
75 | }
76 |
77 | .header__nav .item {
78 | width: 33%
79 | }
80 |
81 | .header__nav .item__link {
82 | width: unset;
83 | background: unset
84 | }
85 |
86 | .header__station-list {
87 | display: none
88 | }
89 |
90 | .content .content__inner {
91 | width: 100%
92 | }
93 |
94 | .top-main-slider {
95 | display: none
96 | }
97 |
98 | /*Only display first 3 item live/timeshift/areafree .*/
99 | .header__nav .item__outer :not(:nth-child(-n+3)) {
100 | display: none
101 | }
102 |
103 | .img-list__channel {
104 | display: inline;
105 | text-align: unset
106 | }
107 |
108 | .img-list__item {
109 | float: unset;
110 | height: unset;
111 | width: unset;
112 | padding: unset;
113 | margin-top: 4px;
114 | border-radius: unset;
115 | margin-right: unset
116 | }
117 |
118 | .img-list__img {
119 | float: right;
120 | width: 45%;
121 | line-height: unset;
122 | margin: unset;
123 | border-radius: unset;
124 | }
125 |
126 | .img-list__img img {
127 | padding-top: 20px
128 | }
129 |
130 | .img-list__title {
131 | margin-top: unset
132 | }
133 |
134 | .img-list__link {
135 | max-height: 150px;
136 | height: 150px
137 | }
138 |
139 | /*to avoid heightline*/
140 | .play {
141 | display: none
142 | }
143 |
144 | .top-sns {
145 | display: none
146 | }
147 |
148 | #top-info {
149 | display: none
150 | }
151 |
152 | .footer {
153 | display: none
154 | }
155 |
156 | .img-list {
157 | margin-top: unset;
158 | margin-right: unset
159 | }
160 |
161 | .heading-lv02 {
162 | display: none
163 | }
164 |
165 | .content {
166 | padding-top: 50px
167 | }
168 |
169 | /*for detail page*/
170 | .heading-area {
171 | display: none
172 | }
173 |
174 | .live-detail__content {
175 | width: unset
176 | }
177 |
178 | .live-detail__main {
179 | width: unset;
180 | padding-right: unset
181 | }
182 |
183 | #pr-image-slider {
184 | display: none
185 | }
186 |
187 | /*for detail page*/
188 | .live-detail__title {
189 | text-align: center
190 | }
191 |
192 | .live-detail__text {
193 | width: unset;
194 | float: unset
195 | }
196 |
197 | .live-detail__img {
198 | float: unset;
199 | margin-right: unset
200 | }
201 |
202 | .live-detail__img img {
203 | margin: 0 auto;
204 | display: block;
205 | width: 100%
206 | }
207 |
208 | #ad-history {
209 | display: none
210 | }
211 |
212 | .live-detail__description {
213 | padding-left: 10px;
214 | padding-right: 10px
215 | }
216 |
217 | .live-detail__cast {
218 | height: unset
219 | }
220 |
221 | .live-detail__cast-title {
222 | display: unset;
223 | float: unset;
224 | width: unset
225 | }
226 |
227 | .live-detail__cast-name {
228 | width: unset;
229 | float: unset
230 | }
231 |
232 | .tune-list {
233 | margin-right: unset
234 | }
235 |
236 | .live-detail__sites {
237 | padding-left: 10px;
238 | padding-right: 10px
239 | }
240 |
241 | .topic-list {
242 | padding-left: 10px;
243 | padding-right: 10px
244 | }
245 |
246 | .live-detail__timeline .timeline-col {
247 | width: unset;
248 | }
249 |
250 | /*for search div*/
251 | /* hide before loaded*/
252 | #to-search {
253 | padding-bottom: 380px
254 | }
255 |
256 | .top-search__form .item__text {
257 | width: 100%;
258 | margin-right: unset;
259 | }
260 |
261 | .top-search__form .item__text .form__text {
262 | width: 100%;
263 | padding: unset;
264 | }
265 |
266 | .top-search__form .item__date {
267 | float: left;
268 | width: 50%;
269 | margin-right: unset;
270 | }
271 |
272 | .top-search__form .item__area {
273 | width: 50%
274 | }
275 |
276 | .top-search__form .item__title {
277 | width: 35%
278 | }
279 |
280 | .top-search__form .item__date .ui-form-select,
281 | .top-search__form .item__date select {
282 | width: 65%
283 | }
284 |
285 | .top-search__form .item__area .ui-form-select,
286 | .top-search__form .item__area select {
287 | width: 65%
288 | }
289 |
290 | .top-search__form .item__submit {
291 | width: 100%
292 | }
293 |
294 | .top-search__form .item__submit .btn {
295 | width: 100%
296 | }
297 |
298 | .top-search__form #search-form .suggest-dropdown {
299 | width: 100%
300 | }
301 |
302 | .search-result__list .img-list__text.separate {
303 | display: none
304 | }
305 |
306 | .search-result__list a {
307 | height: 200px;
308 | max-height: 200px
309 | }
310 |
311 | /*.top-search__form .ui-form-select {width: 65%}*/
312 | .easy-select-box {
313 | width: 100%
314 | }
315 |
316 | #search-form .esb-dropdown {
317 | padding: unset;
318 | }
319 |
320 | #search-form .icon--cross {
321 | display: none
322 | }
323 |
324 | /*hide other program in live page because it conflict with .img-list__link 's height*/
325 | /*#others-program {display: none;}
326 | #others-program .img-list__text {overflow: hidden; white-space: nowrap; text-overflow: ellipsis;}*/
327 | #others-program .img-list__item {
328 | min-height: 200px
329 | }
330 |
331 | #others-program .img-list__genre {
332 | margin-top: 5px
333 | }
334 |
335 | .player-area__volume {
336 | display: none;
337 | }
338 |
339 | /*padding left for button click*/
340 | #player-detail {
341 | width: unset;
342 | position: unset;
343 | padding-left: 122px;
344 | }
345 |
346 | /*just make it out of screen*/
347 | #player-detail .tooltip {
348 | left: -100px
349 | }
350 |
351 | /*dose not work*/
352 | /*TODO #to-search should pop up when click search botton or ..*/
353 |
354 | .tune-list .tune-list__item {
355 | width: unset;
356 | float: unset;
357 | margin-left: 20px;
358 | }
359 |
360 | .page-timeshift .header__nav .item__link {
361 | background-image: unset
362 | }
363 |
364 | .timeshift-detail__attention {
365 | display: none
366 | }
367 |
368 | /*area free*/
369 | .premium-area {
370 | padding: unset
371 | }
372 |
373 | .premium-area__text {
374 | display: none
375 | }
376 |
377 | .icon--attention-02 {
378 | display: none
379 | }
380 |
381 | .content__areafree-top .content__section .tab-list .item {
382 | margin-left: 35px
383 | }
384 |
385 | .content__areafree-top .content__section .tab-list .item:first-child {
386 | margin-left: 35px;
387 | }
388 |
389 | .content__areafree-top .content__section .tab-list .item__selected {
390 | height: 44px;
391 | border: unset;
392 | background-color: #00a7e9
393 | }
394 |
395 | /*share*/
396 | #cboxLoadedContent {
397 | max-width: 100%;
398 | padding: unset;
399 | overflow: unset
400 | }
401 |
402 | #cboxContent {
403 | max-width: 100%
404 | }
405 |
406 | #cboxWrapper {
407 | max-width: 98%;
408 | padding-left: 1%;
409 | min-width: 98%
410 | }
411 |
412 | #colorbox {
413 | max-width: 100%
414 | }
415 |
416 | .colorbox__play .btn-list li a.skip-btn {
417 | margin: 16px 6px 0 6px
418 | }
419 |
420 | .colorbox__share {
421 | margin-top: 0px;
422 | padding-bottom: 10px;
423 | padding-top: 10px
424 | }
425 |
426 | /*TODO: 1.timeshift page select from time or kyoku */
427 | .colorbox__area-list .colorbox-area-list__item {
428 | width: 33%
429 | }
430 |
431 | #colorbox--area-list {
432 | margin: 10px
433 | }
434 |
435 | .timeshift-page-nav {
436 | width: 100%;
437 | }
438 |
439 | /* For header's width fits device screen width, because of a fixed button for timetable */
440 | .top-slider__nav .item--next {
441 | right: 10%;
442 | }
443 |
444 | .top-slider__nav .item--prev {
445 | left: 10%;
446 | }
447 |
448 | .program-table__pager .item--next {
449 | right: 10%;
450 | }
451 |
452 | .program-table__pager .item--prev {
453 | left: 10%;
454 | }
455 |
456 | .program-table__body {
457 | overflow: scroll;
458 | }
459 |
460 | .program-table-header__list-outer {
461 | overflow: scroll;
462 | }
463 |
464 | .program-table__channel-wrapper {
465 | overflow: unset;
466 | }
467 |
468 | .program-table__date-wrapper {
469 | overflow: unset;
470 | }
471 |
472 | .timeshift-detail__sub,
473 | .live-detail__sub {
474 | float: unset;
475 | margin: auto;
476 | width: 90%;
477 | }
478 |
479 | /*
480 | .program-table-header__list {overflow: unset;width: unset;height: unset;}
481 | .program-table-header__list .item:first-child {margin-left: 12px;}
482 | */
483 | /*.program-table__pager .item--prev {position: absolute; left: 0px; z-index: 1;}
484 | .program-table__pager .item--next {position: absolute; right: 0; z-index: 1; }
485 | */
486 | /*.program-table__channel-outer { width: 100%; display: inline-flex; }
487 | .program-table__items {width: 100%}
488 | .program-table__outer {width: 100%;}
489 | .program-table__channel .name img {right: unset;width: 56%}
490 | .program-table__body--col8 .program-table__channel {width: 11%}
491 |
492 | .program-table__date-outer {width: 100%; display: block; }
493 | .program-table__pager {display: none}
494 | .program-table__body--col8 .program-table__date {width: 10% ;padding: 2px}
495 | .program-table__body--col8 .program-table__items .item-outer {width: 11%}*/
496 | /*.program-table-header__list-outer {overflow: scroll;}*/
--------------------------------------------------------------------------------
/ui/mobile_start.js:
--------------------------------------------------------------------------------
1 | document.addEventListener("DOMContentLoaded", function (event) {
2 | var meta = document.createElement('meta');
3 | meta.name = "viewport";
4 | meta.content = "width=" + screen.width;
5 | // Allow user to zoom or not?
6 | //+ ",user-scalable=no";
7 | document.head.appendChild(meta);
8 | document.body.style.display = 'unset';
9 |
10 |
11 | document.getElementById('pause') && document.getElementById('pause').addEventListener('click', function (evnet) {
12 | if (document.getElementById('play').children[0].children[0].classList.contains('on')) {
13 | document.getElementById('pause').children[0].children[0].style.opacity = '0.5';
14 | }
15 |
16 | });
17 | document.getElementById('play') && document.getElementById('play').addEventListener('click', function (evnet) {
18 | if (document.getElementById('pause').children[0].children[0].style.opacity == '0.5') {
19 | setTimeout(function () {
20 | document.getElementById('pause').children[0].children[0].style.opacity = '1';
21 | }, 600);
22 |
23 | }
24 | });
25 | });
26 |
27 | // To bypass Radiko.Device.isMobile check when reloading on #! paths.
28 | function changeMobileUA() {
29 | let change_mobile_ua = document.createElement("script");
30 | // inline script supported in Firefox MV3? Yes. Because:
31 | // and runtime.getURL -> no
32 | // Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
33 | // Scripts in this environment do not have any access to APIs that are only available to content scripts.
34 | change_mobile_ua.textContent = `(function(){Object.defineProperty(window.navigator, 'userAgent', { value: window.navigator.userAgent.replace(/android.*?\;/gi, "").replace(/mobile/gi, "")});})();`
35 | document.head.appendChild(change_mobile_ua);
36 | };
37 |
38 | var executed = false;
39 | document.addEventListener("readystatechange", function (event) {
40 | // complete interactive
41 | if (event.target.readyState !== "loading" && !executed) {
42 | changeMobileUA();
43 | executed = true;
44 | } else {
45 | // loading
46 | document.addEventListener('DOMContentLoaded', changeMobileUA);
47 | }
48 | });
49 |
--------------------------------------------------------------------------------
/ui/recochoku_header_caution.css:
--------------------------------------------------------------------------------
1 | .js-sub-header {
2 | display: none !important;
3 | }
--------------------------------------------------------------------------------
/ui/share_redirect.js:
--------------------------------------------------------------------------------
1 | let checkOverseas = document.getElementsByClassName("messageToOverseas")
2 | if (checkOverseas.length != 0) {
3 | window.addEventListener("message", async function (evt) {
4 | let param = evt.data["share-redirect"] || {};
5 | if (param.t && param.station) {
6 | // Wake up service worker? Is this a bug?
7 | // await chrome.runtime.sendMessage({});
8 | await chrome.runtime.sendMessage({ "share-redirect": param });
9 | }
10 | });
11 |
12 | let inspect_script = document.createElement("script");
13 | inspect_script.src = chrome.runtime.getURL('ui/share_redirect_inject.js');
14 | document.head.appendChild(inspect_script);
15 | }
16 |
--------------------------------------------------------------------------------
/ui/share_redirect_inject.js:
--------------------------------------------------------------------------------
1 | let param = {};
2 | get_share_log_url(param);
3 | window.postMessage({ "share-redirect": param });
--------------------------------------------------------------------------------
/ui/tver_playable_mobile.css:
--------------------------------------------------------------------------------
1 | /* For Firefox Android TVer .player_host__CCajQ remove min-width: 512px; */
2 | div[class^="player_host"] {
3 | min-width: unset !important;
4 | }
--------------------------------------------------------------------------------
/ui/tver_playable_ua_inspect.js:
--------------------------------------------------------------------------------
1 | (() => {
2 | Object.defineProperty(
3 | window.navigator,
4 | 'userAgent',
5 | {
6 | value: window.navigator.userAgent.replaceAll(/linux/gi, "Windows").replaceAll(/android.*?;/gi, "Windows;")
7 | }
8 | );
9 | })();
10 |
--------------------------------------------------------------------------------