├── locale
├── es_ES
│ ├── manifest
│ └── translations.xml
└── default
│ ├── manifest
│ ├── images
│ ├── splash_hd.png
│ ├── splash_sd.png
│ ├── splash_fhd.png
│ ├── transparent.png
│ ├── poster_error.png
│ ├── poster_loading.png
│ ├── safe_area_fhd.png
│ ├── safe_area_hd.png
│ ├── list_selection.9.png
│ ├── overhang_logo_hd.png
│ ├── channel_poster_hd.png
│ ├── channel_poster_sd.png
│ ├── poster_error_vertical.png
│ ├── poster_loading_vertical.png
│ ├── history_label_list_icon_games.png
│ ├── history_label_list_icon_streams.png
│ └── history_label_list_icon_channels.png
│ └── translations.xml
├── .gitignore
├── third_party.txt
├── .rokudevignore
├── bitbucket-pipelines.yml
├── components
├── ChatItemData.xml
├── PosterRowListItemData.xml
├── FontTest.xml
├── VodItemData.xml
├── FontUtil.xml
├── VideoGridItemData.xml
├── Registry.xml
├── Ads.xml
├── Irc.xml
├── ChatItem.xml
├── VodItem.brs
├── PosterRowListItem.brs
├── VodItem.xml
├── VideoTitle.xml
├── VideoMessage.xml
├── PosterRowListItem.xml
├── VideoGridItem.brs
├── VideoGridItem.xml
├── Chat.xml
├── FontUtil.brs
├── Search.xml
├── LinkScreen.xml
├── Settings.xml
├── TwitchApi.xml
├── Registry.brs
├── LinkScreen.brs
├── InfoScreen.xml
├── Util.brs
├── Twitch.xml
├── Chat.brs
├── ChatItem.brs
├── HlsUtil.brs
├── Search.brs
├── Ads.brs
├── Settings.brs
├── Irc.brs
└── InfoScreen.brs
├── secret.json.example
├── .github
└── issue_template.md
├── check_for_stops.sh
├── manifest
├── license.txt
├── readme.md
└── resources
└── twitch_lang.json
/locale/es_ES/manifest:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/locale/default/manifest:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | out/
2 | .project
3 | .settings/
4 | secret.json
5 | dumps/
6 |
--------------------------------------------------------------------------------
/locale/default/images/splash_hd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/splash_hd.png
--------------------------------------------------------------------------------
/locale/default/images/splash_sd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/splash_sd.png
--------------------------------------------------------------------------------
/locale/default/images/splash_fhd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/splash_fhd.png
--------------------------------------------------------------------------------
/locale/default/images/transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/transparent.png
--------------------------------------------------------------------------------
/locale/default/images/poster_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/poster_error.png
--------------------------------------------------------------------------------
/locale/default/images/poster_loading.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/poster_loading.png
--------------------------------------------------------------------------------
/locale/default/images/safe_area_fhd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/safe_area_fhd.png
--------------------------------------------------------------------------------
/locale/default/images/safe_area_hd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/safe_area_hd.png
--------------------------------------------------------------------------------
/locale/default/images/list_selection.9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/list_selection.9.png
--------------------------------------------------------------------------------
/locale/default/images/overhang_logo_hd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/overhang_logo_hd.png
--------------------------------------------------------------------------------
/third_party.txt:
--------------------------------------------------------------------------------
1 | The Twitched and Twitched Zero Roku app may use or contain portions of the
2 | following assets, subject to the below licenses.
3 |
--------------------------------------------------------------------------------
/.rokudevignore:
--------------------------------------------------------------------------------
1 | readme.md
2 | license.txt
3 | secret.json.example
4 | third_party.txt
5 | bitbucket-pipelines.yml
6 | check_for_stops.sh
7 | dumps/
8 |
--------------------------------------------------------------------------------
/locale/default/images/channel_poster_hd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/channel_poster_hd.png
--------------------------------------------------------------------------------
/locale/default/images/channel_poster_sd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/channel_poster_sd.png
--------------------------------------------------------------------------------
/locale/default/images/poster_error_vertical.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/poster_error_vertical.png
--------------------------------------------------------------------------------
/locale/default/images/poster_loading_vertical.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/poster_loading_vertical.png
--------------------------------------------------------------------------------
/bitbucket-pipelines.yml:
--------------------------------------------------------------------------------
1 | image: atlassian/default-image:latest
2 |
3 | pipelines:
4 | default:
5 | - step:
6 | script:
7 | - ./check_for_stops.sh
8 |
--------------------------------------------------------------------------------
/locale/default/images/history_label_list_icon_games.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/history_label_list_icon_games.png
--------------------------------------------------------------------------------
/locale/default/images/history_label_list_icon_streams.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/history_label_list_icon_streams.png
--------------------------------------------------------------------------------
/locale/default/images/history_label_list_icon_channels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrozenIronSoftware/Twitched/HEAD/locale/default/images/history_label_list_icon_channels.png
--------------------------------------------------------------------------------
/components/ChatItemData.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/secret.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "api_kraken": "Remove if not used / remove in production",
3 | "api_helix": "Remove if not used / remove in production",
4 | "api": "Remove if not used / remove in production",
5 | "client_id": "",
6 | "client_id_twitch": "",
7 | "log_level": "INFO/DEBUG/EXTRA/VERBOSE",
8 | "ad_nielsen_id": "",
9 | "google_analytics_id": "",
10 | "safe_area_overlay": false
11 | }
--------------------------------------------------------------------------------
/components/PosterRowListItemData.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/components/FontTest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/components/VodItemData.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.github/issue_template.md:
--------------------------------------------------------------------------------
1 | ### Description
2 | [Description of the issue]
3 |
4 | ### Steps to Reproduce
5 | 1. [First step]
6 | 2. [Second step]
7 |
8 | ...
9 |
10 | **Expected behavior:** [What was supposed to happen]
11 |
12 | **Actual behavior:** [What actually happened]
13 |
14 | **Roku Model:** [Model]
15 |
16 | **Roku Firmware Version:** [Firmware version]
17 |
18 | **Twitched Version:** [Version number]
19 |
20 | ### Additional Information
21 | [Any other information related to the problem]
22 |
--------------------------------------------------------------------------------
/components/FontUtil.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/components/VideoGridItemData.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/check_for_stops.sh:
--------------------------------------------------------------------------------
1 | # Checks source and components directories for files with the word stop in them
2 | # Comments will also be matched
3 |
4 | out="$(grep -Rin "stop" source | grep -v "'" | grep -v "\"")"
5 | status="$(echo \"$out\" | grep -v \"[\^\\s\\r\\n]\*\")"
6 | if [ -n "$status" ]; then
7 | echo "$out"
8 | exit 1
9 | fi
10 | out="$(grep -Rin "stop" components | grep -v "'" | grep -v "\"")"
11 | status="$(echo \"$out\" | grep -v \"[\^\\s\\r\\n]\*\")"
12 | if [ -n "$status" ]; then
13 | echo "$out"
14 | exit 2
15 | fi
16 |
--------------------------------------------------------------------------------
/components/Registry.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/manifest:
--------------------------------------------------------------------------------
1 | title=Twitched
2 | major_version=1
3 | minor_version=7
4 | build_version=3017
5 | mm_icon_focus_hd=pkg:/locale/default/images/channel_poster_hd.png
6 | mm_icon_focus_sd=pkg:/locale/default/images/channel_poster_sd.png
7 | splash_screen_fhd=pkg:/locale/default/images/splash_fhd.png
8 | splash_screen_hd=pkg:/locale/default/images/splash_hd.png
9 | splash_screen_sd=pkg:/locale/default/images/splash_sd.png
10 | splash_color=#6441a4
11 | splash_min_time=1600
12 | screensaver_private=1
13 | ui_resolutions=fhd
14 | sg_component_libs_required=Roku_Analytics
15 | bs_const=enable_ads=false
16 | #bs_libs_required=roku_ads_lib
17 | requires_widevine_drm=1
18 | requires_widevine_version=1.0
19 |
--------------------------------------------------------------------------------
/components/Ads.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/components/Irc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/components/ChatItem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
19 |
20 |
22 |
23 |
25 |
26 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/components/VodItem.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the ChatItem component
4 | function init() as void
5 | ' Constants
6 | ' Components
7 | m.image = m.top.findNode("image")
8 | m.title = m.top.findNode("title")
9 | ' Variables
10 | ' Init
11 | init_logging()
12 | ' Events
13 | m.top.observeField("itemContent", "on_item_content_change")
14 | m.top.observeField("itemHasFocus", "on_focus_change")
15 | end function
16 |
17 | ' Handle focus change
18 | function on_focus_change(event as object) as void
19 | if event.getData()
20 | m.title.repeatCount = -1
21 | else
22 | m.title.repeatCount = 0
23 | end if
24 | end function
25 |
26 | ' Handle a content change
27 | function on_item_content_change(event as object) as void
28 | m.image.uri = event.getData().image_url
29 | m.title.text = event.getData().title
30 | end function
--------------------------------------------------------------------------------
/components/PosterRowListItem.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2018 Frozen Iron Software. All Rights Reserved.
2 |
3 | ' Create a new instance of the VideoGridItem component
4 | function init() as void
5 | ' Constants
6 | ' Components
7 | m.image = m.top.findNode("image")
8 | m.title = m.top.findNode("title")
9 | ' Variables
10 | ' Init
11 | init_logging()
12 | ' Events
13 | m.top.observeField("itemContent", "on_item_content_change")
14 | m.top.observeField("itemHasFocus", "on_focus_change")
15 | end function
16 |
17 | ' Handle focus change
18 | function on_focus_change(event as object) as void
19 | if event.getData()
20 | m.title.repeatCount = -1
21 | else
22 | m.title.repeatCount = 0
23 | end if
24 | end function
25 |
26 | ' Handle a content change
27 | function on_item_content_change(event as object) as void
28 | m.image.uri = event.getData().image_url
29 | m.title.text = event.getData().title
30 | end function
--------------------------------------------------------------------------------
/components/VodItem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/components/VideoTitle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
26 |
27 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/components/VideoMessage.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
26 |
27 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/components/PosterRowListItem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/license.txt:
--------------------------------------------------------------------------------
1 | "Twitched" Copyright (C) 2017 Rolando Islas, 2018 Frozen Iron Software LLC. All Rights Reserved.
2 |
3 | Twitched is a Roku/Apple TV (media player) app for connecting to Twitch (live streaming service)
4 | and watching live streams. The Twitched website/API serves as a proxy/cache for all request to
5 | Twitch.
6 |
7 | Twitched is not affiliated with Twitch Interactive, Inc., Roku, Inc., or Apple, Inc.
8 |
9 | This file is part of the Twitched.
10 |
11 | Twitched is free software: you can redistribute it and/or modify
12 | it under the terms of the GNU General Public License as published by
13 | the Free Software Foundation, either version 2 of the License, or
14 | (at your option) any later version.
15 |
16 | Twitched is distributed in the hope that it will be useful,
17 | but WITHOUT ANY WARRANTY; without even the implied warranty of
18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 | GNU General Public License for more details.
20 |
21 | You should have received a copy of the GNU General Public License
22 | along with Twitched. If not, see .
23 |
--------------------------------------------------------------------------------
/components/VideoGridItem.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the VideoGridItem component
4 | function init() as void
5 | ' Constants
6 | ' Components
7 | m.image = m.top.findNode("image")
8 | m.title = m.top.findNode("title")
9 | m.description = m.top.findNode("description")
10 | m.game = m.top.findNode("game")
11 | ' Variables
12 | ' Init
13 | init_logging()
14 | ' Events
15 | m.top.observeField("itemContent", "on_item_content_change")
16 | m.top.observeField("itemHasFocus", "on_focus_change")
17 | end function
18 |
19 | ' Handle focus change
20 | function on_focus_change(event as object) as void
21 | if event.getData()
22 | m.title.repeatCount = -1
23 | else
24 | m.title.repeatCount = 0
25 | end if
26 | end function
27 |
28 | ' Handle a content change
29 | function on_item_content_change(event as object) as void
30 | m.image.uri = event.getData().image_url
31 | m.title.text = event.getData().title
32 | m.description.text = event.getData().description
33 | m.game.uri = event.getData().game_image
34 | end function
--------------------------------------------------------------------------------
/components/VideoGridItem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
21 |
22 |
31 |
32 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/components/Chat.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
21 |
22 |
23 |
24 |
26 |
27 |
30 |
31 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/components/FontUtil.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2018 Rolando Islas. All Rights Reserved.
2 |
3 | ' FontUtil entry point
4 | function init() as void
5 | ' Constants
6 | m.PORT = createObject("roMessagePort")
7 | ' Components
8 | m.font_registry = createObject("roFontRegistry")
9 | ' Events
10 | m.top.observeField("get_size", m.PORT)
11 | ' Variables
12 | m.cache = {}
13 | ' Init
14 | init_logging()
15 | ' Task init
16 | m.top.functionName = "run"
17 | m.top.control = "RUN"
18 | end function
19 |
20 | ' Main task loop
21 | function run() as void
22 | printl(m.DEBUG, "FontUtil: Task started")
23 | while true
24 | msg = wait(0, m.PORT)
25 | ' Field event
26 | if type(msg) = "roSGNodeEvent"
27 | if msg.getField() = "get_size"
28 | get_size(msg.getData())
29 | end if
30 | end if
31 | end while
32 | end function
33 |
34 | ' Get the width and height of a string with the specified font size
35 | ' @param params roArray [text as string, font_size as integer, callback as string]
36 | function get_size(params as object) as void
37 | text = params[0]
38 | font_size = params[1]
39 | font = m.cache[font_size.toStr()]
40 | if font = invalid
41 | font = m.font_registry.getDefaultFont(font_size, false, false)
42 | m.cache[font_size.toStr()] = font
43 | end if
44 | m.top.result = {
45 | callback: params[2],
46 | result: {
47 | ' FIXME font width returned is roughly 6.5% smaller than label text
48 | width: font.getOneLineWidth(text, 1920) * 1.065,
49 | height: font.getOneLineHeight()
50 | }
51 | }
52 | end function
--------------------------------------------------------------------------------
/components/Search.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
9 |
10 |
15 |
16 |
28 |
29 |
42 |
43 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Twitched
2 |
3 | Twitch app for the Roku media player
4 |
5 | **This repo is no longer maintained.**
6 |
7 | # Installing
8 |
9 | The repo root can be zipped and installed onto a Roku with the developer
10 | mode enabled.
11 |
12 | ## Paid/Free (Ads)
13 |
14 | Twitched includes two releases: Twitched and Twitched Zero.
15 |
16 | ### Twitched
17 |
18 | - `bs_const=enable_ads=false` is set to false in the main manifest
19 | - `bs_libs_required=roku_ads_lib` is **not** present or commented out in the main manifest
20 | - components/Ads.brs and components/Ads.xml are **not** included in the
21 | app package
22 |
23 | ### Twitched Zero
24 |
25 | - `bs_const=enable_ads=true` is set to true in the main manifest
26 | - `bs_libs_required=roku_ads_lib` is present in the main manifest
27 | - components/Ads.brs and components/Ads.xml are included in the app package
28 | - Name is changed in the main manifest
29 | - Logo, splash, and channel poster are updated
30 |
31 | See [Loading and Running Your Application] on the Roku documentation site.
32 |
33 | ## secret.json
34 |
35 | The secret.json file is required. At minimum it should include an empty JSON
36 | object `{}`. All fields are located in the **secret.json.example** file.
37 |
38 | # API
39 |
40 | Twitched does not use Twitch API endpoints directly. Instead, it uses an API
41 | proxy/cache that handles caching requests and modifies some return values to
42 | make ingesting the API easier.
43 |
44 | See the [Twitched API].
45 |
46 | # License
47 |
48 | GPLv2. See license.txt for more information.
49 |
50 |
51 |
52 | [Loading and Running Your Application]: https://sdkdocs.roku.com/display/sdkdoc/Loading+and+Running+Your+Application
53 | [Twitched API]: https://github.com/FrozenIronSoftware/TwitchedApi
54 |
--------------------------------------------------------------------------------
/components/LinkScreen.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
23 |
24 |
35 |
36 |
47 |
48 |
59 |
60 |
71 |
72 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/components/Settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
19 |
20 |
31 |
32 |
43 |
44 |
50 |
51 |
52 |
53 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/components/TwitchApi.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/components/Registry.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Init registry component
4 | function init() as void
5 | m.port = createObject("roMessagePort")
6 | ' Events
7 | m.top.observeField("read", m.port)
8 | m.top.observeField("write", m.port)
9 | m.top.observeField("write_multi", m.port)
10 | m.top.observeField("read_multi", m.port)
11 | ' Task init
12 | m.top.functionName = "run"
13 | m.top.control = "RUN"
14 | end function
15 |
16 | ' Main task loop
17 | function run() as void
18 | print("Registry task started")
19 | while true
20 | msg = wait(0, m.port)
21 | if type(msg) = "roSGNodeEvent"
22 | if msg.getField() = "read"
23 | read(msg)
24 | else if msg.getField() = "write"
25 | write(msg)
26 | else if msg.getField() = "write_multi"
27 | write_multi(msg)
28 | else if msg.getField() = "read_multi"
29 | read_multi(msg)
30 | end if
31 | end if
32 | end while
33 | end function
34 |
35 | ' Read a registry key
36 | ' @param params array [string section, string key, string callback]
37 | function read(params as object) as void
38 | reg = createObject("roRegistrySection", params.getData()[0])
39 | m.top.setField("result", {
40 | type: "read",
41 | section: params.getData()[0],
42 | key: params.getData()[1],
43 | callback: params.getData()[2],
44 | result: reg.read(params.getData()[1])
45 | })
46 | end function
47 |
48 | ' Write a registry key
49 | ' @param params array [string section, string key, string value, string callback]
50 | function write(params as object) as void
51 | reg = createObject("roRegistrySection", params.getData()[0])
52 | write_status = reg.write(params.getData()[1], params.getData()[2]) and reg.flush()
53 | m.top.setField("result", {
54 | type: "write",
55 | section: params.getData()[0],
56 | key: params.getData()[1],
57 | callback: params.getData()[3],
58 | result: write_status
59 | })
60 | end function
61 |
62 | ' Write multiple values to the registry
63 | ' @param params array [string section, assocarray key_value_pairs, string callback]
64 | function write_multi(params as object) as void
65 | section_name = params.getData()[0]
66 | key_val = params.getData()[1]
67 | callback = params.getData()[2]
68 | reg = createObject("roRegistrySection", section_name)
69 | write_status = reg.writeMulti(key_val) and reg.flush()
70 | m.top.setField("result", {
71 | type: "write_multi",
72 | section: section_name,
73 | key: key_val,
74 | callback: callback
75 | result: write_status
76 | })
77 | end function
78 |
79 | ' Read multiple values from the registry
80 | ' @param params array [string section, array keys, string callback]
81 | function read_multi(params as object) as void
82 | section_name = params.getData()[0]
83 | keys = params.getData()[1]
84 | callback = params.getData()[2]
85 | reg = createObject("roRegistrySection", section_name)
86 | key_val = reg.readMulti(keys)
87 | m.top.setField("result", {
88 | type: "read_multi",
89 | section: section_name,
90 | key: keys,
91 | callback: callback
92 | result: key_val
93 | })
94 | end function
--------------------------------------------------------------------------------
/resources/twitch_lang.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "code": "all",
4 | "name_en": "All",
5 | "name": "All"
6 | },
7 | {
8 | "code": "en",
9 | "name_en": "English",
10 | "name": "English"
11 | },
12 | {
13 | "code": "da",
14 | "name_en": "Danish",
15 | "name": "Dansk"
16 | },
17 | {
18 | "code": "de",
19 | "name_en": "German",
20 | "name": "Deutsch"
21 | },
22 | {
23 | "code": "es",
24 | "name_en": "Spanish",
25 | "name": "Español"
26 | },
27 | {
28 | "code": "fr",
29 | "name_en": "French",
30 | "name": "Français"
31 | },
32 | {
33 | "code": "it",
34 | "name_en": "Italian",
35 | "name": "Italiano"
36 | },
37 | {
38 | "code": "hu",
39 | "name_en": "Hungarian",
40 | "name": "Magyar"
41 | },
42 | {
43 | "code": "nl",
44 | "name_en": "Dutch",
45 | "name": "Nederlands"
46 | },
47 | {
48 | "code": "no",
49 | "name_en": "Norwegian",
50 | "name": "Norsk"
51 | },
52 | {
53 | "code": "pl",
54 | "name_en": "Polish",
55 | "name": "Polski"
56 | },
57 | {
58 | "code": "pt",
59 | "name_en": "Portuguese",
60 | "name": "Português"
61 | },
62 | {
63 | "code": "sk",
64 | "name_en": "Slovak",
65 | "name": "Slovenčina"
66 | },
67 | {
68 | "code": "fi",
69 | "name_en": "Finnish",
70 | "name": "Suomi"
71 | },
72 | {
73 | "code": "sv",
74 | "name_en": "Swedish",
75 | "name": "Svenska"
76 | },
77 | {
78 | "code": "vi",
79 | "name_en": "Vietnamese",
80 | "name": "Tiếng Việt"
81 | },
82 | {
83 | "code": "tr",
84 | "name_en": "Turkish",
85 | "name": "Türkçe"
86 | },
87 | {
88 | "code": "cs",
89 | "name_en": "Czech",
90 | "name": "Čeština"
91 | },
92 | {
93 | "code": "el",
94 | "name_en": "Greek",
95 | "name": "Ελληνικά"
96 | },
97 | {
98 | "code": "bg",
99 | "name_en": "Bulgarian",
100 | "name": "Български"
101 | },
102 | {
103 | "code": "ru",
104 | "name_en": "Russian",
105 | "name": "Русский"
106 | },
107 | {
108 | "code": "ar",
109 | "name_en": "Arabic",
110 | "name": "العربية"
111 | },
112 | {
113 | "code": "th",
114 | "name_en": "Thai",
115 | "name": "ภาษาไทย"
116 | },
117 | {
118 | "code": "zh",
119 | "name_en": "Chinese",
120 | "name": "中文"
121 | },
122 | {
123 | "code": "zh-hk",
124 | "name_en": "Chinese (Cantonese)",
125 | "name": "中文(粵語)"
126 | },
127 | {
128 | "code": "ja",
129 | "name_en": "Japanese",
130 | "name": "日本語"
131 | },
132 | {
133 | "code": "ko",
134 | "name_en": "Korean",
135 | "name": "한국어"
136 | },
137 | {
138 | "code": "asl",
139 | "name_en": "American Sign Language",
140 | "name": "American Sign Language"
141 | },
142 | {
143 | "code": "other",
144 | "name_en": "Other",
145 | "name": "Other"
146 | }
147 | ]
148 |
--------------------------------------------------------------------------------
/components/LinkScreen.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the InfoScreen component
4 | function init() as void
5 | m.port = createObject("roMessagePort")
6 | ' Components
7 | m.title = m.top.findNode("title")
8 | m.message = m.top.findNode("message")
9 | m.code = m.top.findNode("code")
10 | m.message_footer = m.top.findNode("message_footer")
11 | m.url = m.top.findNode("url")
12 | m.twitch_api = createObject("roSGNode", "TwitchApi")
13 | m.timer = m.top.findNode("timer")
14 | ' Init
15 | m.title.text = tr("title_link")
16 | m.message.text = tr("message_link").replace("{1}", m.twitch_api.AUTH_URL)
17 | m.message_footer.text = tr("message_link_close")
18 | m.url.text = m.twitch_api.AUTH_URL
19 | ' Events
20 | m.top.observeField("do_link", "do_link")
21 | m.twitch_api.observeField("result", "on_callback")
22 | m.timer.observeField("fire", "on_timer")
23 | end function
24 |
25 | ' Handle callback
26 | function on_callback(event as object) as void
27 | callback = event.getData().callback
28 | if callback = "on_link_code"
29 | on_link_code(event)
30 | else if callback = "on_link_status"
31 | on_link_status(event)
32 | else
33 | if callback = invalid
34 | callback = ""
35 | end if
36 | printl(m.WARN, "on_callback: Unhandled callback: " + callback)
37 | end if
38 | end function
39 |
40 | ' Begin the link process
41 | ' Ignores event
42 | function do_link(event as object) as void
43 | ' Ensure the timer and any http requests are stopped
44 | m.timer.control = "stop"
45 | m.twitch_api.cancel = true
46 | ' Request the link code
47 | m.code.text = tr("message_loading")
48 | m.twitch_api.get_link_code = "on_link_code"
49 | end function
50 |
51 | ' Given an event with link code data, validate and begin the request loop
52 | ' waiting for authentication
53 | function on_link_code(event as object) as void
54 | link_code = event.getData().result
55 | if type(link_code) <> "roAssociativeArray"
56 | m.top.setField("error", 2000)
57 | return
58 | else if link_code.id = invalid
59 | m.top.setField("error", 2001)
60 | return
61 | end if
62 | ' Set code on screen
63 | m.code.text = link_code.id
64 | ' Start timer
65 | m.timer.control = "start"
66 | end function
67 |
68 | ' Handle a timer event
69 | ' Request the status of a link code
70 | function on_timer(event as object) as void
71 | ' Stop any active request
72 | m.twitch_api.cancel = true
73 | ' Stop the loop if the link screen is not up
74 | if not m.top.visible
75 | m.timer.control = "stop"
76 | return
77 | end if
78 | ' Request link status
79 | m.twitch_api.get_link_status = "on_link_status"
80 | end function
81 |
82 | ' Handle link status
83 | function on_link_status(event as object) as void
84 | status = event.getData().result
85 | if type(status) <> "roAssociativeArray"
86 | m.top.setField("error", 2002)
87 | m.timer.contol = "stop"
88 | return
89 | end if
90 | ' Check the status
91 | if status.error <> invalid
92 | print status.error
93 | m.top.setField("timeout", true)
94 | m.timer.control = "stop"
95 | else if status.complete
96 | if status.token <> invalid
97 | m.top.setField("linked_token", status)
98 | else
99 | m.top.setField("error", 2003)
100 | end if
101 | m.timer.control = "stop"
102 | end if
103 | end function
--------------------------------------------------------------------------------
/components/InfoScreen.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
19 |
20 |
30 |
31 |
36 |
37 |
40 |
41 |
51 |
52 |
62 |
63 |
73 |
74 |
84 |
85 |
86 |
87 |
111 |
112 |
113 |
114 |
116 |
117 |
127 |
128 |
131 |
132 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
--------------------------------------------------------------------------------
/components/Util.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Determine if the key should be singular or plural based on the amount and
4 | ' find the correct translation
5 | function trs(key as string, amount as integer) as string
6 | if amount = 1 or amount = -1
7 | return tr(key + "_singular")
8 | end if
9 | return tr(key + "_plural")
10 | end function
11 |
12 | ' Clean a string that may have invalid characters.
13 | function clean(dirty as object) as string
14 | if m._clean_regex = invalid
15 | m._clean_regex = createObject("roRegex", "[^A-Za-z0-9\s!@#$%^&*()_\-+=<,>\./\?';\:\[\]\{\}\\\|" + chr(34) + "]", "")
16 | end if
17 | if type(dirty) <> "roString" and type(dirty) <> "String" and type(dirty) <> "string"
18 | return ""
19 | end if
20 | return m._clean_regex.replaceAll(dirty, "")
21 | end function
22 |
23 | ' Log a message
24 | ' @param level log level string or integer
25 | ' @param msg message to print
26 | function printl(level as object, msg as object) as void
27 | if _parse_level(level) > m.log_level
28 | return
29 | end if
30 | print(msg)
31 | end function
32 |
33 | ' Parse level to an integer
34 | ' @param level string or integer level
35 | function _parse_level(level as object) as integer
36 | level_string = level.toStr()
37 | log_level = 0
38 | if level_string = "INFO" or level_string = "0"
39 | log_level = m.INFO
40 | else if level_string = "DEBUG" or level_string = "1"
41 | log_level = m.DEBUG
42 | else if level_string = "EXTRA" or level_string = "2"
43 | log_level = m.EXTRA
44 | else if level_string = "VERBOSE" or level_string = "3"
45 | log_level = m.VERBOSE
46 | end if
47 | return log_level
48 | end function
49 |
50 | ' Initialize logging
51 | function init_logging() as void
52 | m.INFO = 0
53 | m.DEBUG = 1
54 | m.EXTRA = 2
55 | m.VERBOSE = 3
56 | level_string = m.global.secret.log_level
57 | log_level = 0
58 | if level_string = "INFO" or level_string = "0"
59 | log_level = m.INFO
60 | else if level_string = "DEBUG" or level_string = "1"
61 | log_level = m.DEBUG
62 | else if level_string = "EXTRA" or level_string = "2"
63 | log_level = m.EXTRA
64 | else if level_string = "VERBOSE" or level_string = "3"
65 | log_level = m.VERBOSE
66 | end if
67 | m.log_level = log_level
68 | end function
69 |
70 | ' Returns a string representation of a number, with delimiters added for
71 | ' readability
72 | function pretty_number(ugly_number as dynamic) as string
73 | ' Check if the number is large enough for a delimiter
74 | if ugly_number < 1000
75 | return ugly_number.toStr()
76 | end if
77 | ' Determine delimiter
78 | delimiter = get_regional_number_delimiter()
79 | ' Construct the string with the delimiter
80 | ugly = ugly_number.toStr().split("")
81 | ugly_reversed = []
82 | for digit = ugly.count() - 1 to 0 step -1
83 | ugly_reversed.Push(ugly[digit])
84 | end for
85 | ugly = ugly_reversed
86 | pretty = ""
87 | digit_count = 0
88 | for each digit in ugly
89 | if digit_count = 3
90 | pretty = delimiter + pretty
91 | digit_count = 0
92 | end if
93 | pretty = digit + pretty
94 | digit_count++
95 | end for
96 | return pretty
97 | end function
98 |
99 | ' Return the character used to delimit thousands in a number
100 | function get_regional_number_delimiter() as string
101 | device_info = createObject("roDeviceInfo")
102 | country_code = device_info.getCountryCode()
103 | if country_code = "US" or country_code = "GB" or country_code = "IE"
104 | return ","
105 | else if country_code = "CA" or country_code = "FR"
106 | return " "
107 | else if country_code = "MX"
108 | return "."
109 | else if country_code = "OT"
110 | return " "
111 | end if
112 | return " "
113 | end function
114 |
115 | ' Check if an array contains the given value
116 | function array_contains(search_array as object, search_item as object) as boolean
117 | for each item in search_array
118 | if item = search_item
119 | return true
120 | end if
121 | end for
122 | return false
123 | end function
124 |
125 | ' Return the smallest of two numbers
126 | function min(a as dynamic, b as dynamic) as dynamic
127 | if a < b
128 | return a
129 | else
130 | return b
131 | end if
132 | end function
133 |
134 | ' Returns true if the manufactuer is TCL.
135 | function is_tcl_device() as boolean
136 | device_info = createObject("roDeviceInfo")
137 | model_details = device_info.getModelDetails()
138 | return model_details["VendorName"] = "TCL"
139 | end function
140 |
--------------------------------------------------------------------------------
/components/Twitch.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
20 |
21 |
33 |
34 |
35 |
36 |
48 |
49 |
66 |
67 |
69 |
70 |
72 |
73 |
75 |
76 |
87 |
88 |
92 |
93 |
97 |
98 |
102 |
103 |
109 |
110 |
114 |
115 |
118 |
119 |
122 |
123 |
126 |
127 |
131 |
132 |
135 |
136 |
140 |
141 |
145 |
146 |
150 |
151 |
157 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
--------------------------------------------------------------------------------
/components/Chat.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the Chat component
4 | function init() as void
5 | ' Constants
6 | m.CHAT_ITEMS = 5
7 | ' Components
8 | m.irc = m.top.findNode("irc")
9 | m.chat_list = m.top.findNode("chat_list")
10 | m.keyboard = m.top.findNode("keyboard")
11 | m.chat_timer = m.top.findNode("chat_timer")
12 | ' Vars
13 | m.queued_messages = []
14 | ' Init
15 | init_logging()
16 | init_chat_list()
17 | m.chat_timer.control = "start"
18 | m.top.delay_seconds = 25
19 | ' Events
20 | m.top.observeField("connect", "connect")
21 | m.top.observeField("disconnect", "disconnect")
22 | m.top.observeField("token", "set_token")
23 | m.top.observeField("user_name", "set_user_name")
24 | m.top.observeField("do_input", "activate_input")
25 | m.top.observeField("visible", "on_visibility_change")
26 | m.irc.observeField("chat_message", "on_chat_message")
27 | m.keyboard.observeField("buttonSelected", "on_keyboard_button_selected")
28 | m.chat_timer.observeField("fire", "update_message")
29 | end function
30 |
31 | ' Handle key events
32 | function onKeyEvent(key as string, press as boolean) as boolean
33 | return false
34 | end function
35 |
36 | ' Add chat items
37 | function init_chat_list() as void
38 | for item_index = 0 to m.CHAT_ITEMS - 1
39 | chat_item = m.chat_list.content.createChild("ChatItemData")
40 | end for
41 | end function
42 |
43 | ' Handle connecting
44 | ' Event is a field event with the value being a string with the streamer name
45 | function connect(event as object) as void
46 | m.queued_messages = []
47 | set_connecting_message()
48 | m.irc.connect = event.getData()
49 | end function
50 |
51 | ' Add a message to indicate the chat is connecting
52 | function set_connecting_message() as void
53 | add_chat_message({
54 | name: tr("twitched")
55 | color: "#ffffff"
56 | message: tr("message_irc_connecting")
57 | })
58 | end function
59 |
60 | ' Handle disconnect
61 | ' Event is a field event with the value being ignored
62 | function disconnect(event as object) as void
63 | m.irc.disconnect = true
64 | m.queued_messages = []
65 | end function
66 |
67 | ' Handle a chat message
68 | function on_chat_message(event as object) as void
69 | message = event.getData()
70 | if m.global.do_delay_chat
71 | queue_chat_message(message)
72 | else
73 | add_chat_message(message)
74 | end if
75 | end function
76 |
77 | ' Queue a chat message to be added later by the delayed loop
78 | function queue_chat_message(message as object) as void
79 | message_time = uptime(0)
80 | message_packet = {
81 | time: message_time,
82 | message: message
83 | }
84 | if message.do_not_queue = true
85 | message_packet.time = 0
86 | end if
87 | m.queued_messages.push(message_packet)
88 | end function
89 |
90 | ' Add a chat message to the chat list
91 | function add_chat_message(message as object) as void
92 | ' Set the message to an empty chat item
93 | for item_index = 0 to m.CHAT_ITEMS - 1
94 | chat_item = m.chat_list.content.getChild(item_index)
95 | if type(chat_item.message) <> "roAssociativeArray"
96 | chat_item.message = message
97 | return
98 | end if
99 | end for
100 | ' No chat items were empty. Delete the first and create a new one.
101 | m.chat_list.content.removeChildIndex(0)
102 | chat_item = m.chat_list.content.createChild("ChatItemData")
103 | chat_item.message = message
104 | end function
105 |
106 | ' Set user token
107 | function set_token(event as object) as void
108 | m.irc.token = event.getData()
109 | end function
110 |
111 | ' Set user name
112 | function set_user_name(event as object) as void
113 | m.irc.user_name = event.getData()
114 | end function
115 |
116 | ' Handle an input request
117 | ' Expects a sgnode event with the field data being a boolean
118 | function activate_input(event as object) as void
119 | if event.getData() and m.top.user_name <> "" and m.top.token <> ""
120 | m.keyboard.title = tr("title_chat")
121 | m.keyboard.buttons = [tr("button_confirm"), tr("button_cancel")]
122 | m.keyboard.keyboard.text = ""
123 | m.keyboard.visible = true
124 | m.keyboard.setFocus(true)
125 | else
126 | m.keyboard.visible = false
127 | end if
128 | end function
129 |
130 | ' Handle a button press on the keyboard dialog
131 | ' 0 confirm, 1 cancel
132 | function on_keyboard_button_selected(event as object) as void
133 | input = m.keyboard.keyboard.text
134 | confirm = event.getData() = 0
135 | if confirm
136 | if input <> invalid and input <> ""
137 | m.irc.send_chat_message = input
138 | end if
139 | end if
140 | m.keyboard.visible = false
141 | m.top.setField("blur", true)
142 | end function
143 |
144 | ' Hide keyboard on visibility change
145 | function on_visibility_change(event as object) as void
146 | m.keyboard.visible = false
147 | end function
148 |
149 | ' Handle update message loop
150 | function update_message(event as object) as void
151 | time = uptime(0)
152 | to_remove = 0
153 | for each message in m.queued_messages
154 | if time - message.time >= m.top.delay_seconds
155 | add_chat_message(message.message)
156 | to_remove += 1
157 | else
158 | goto remove_messages
159 | end if
160 | end for
161 | remove_messages:
162 | for remove_index = 0 to to_remove - 1
163 | m.queued_messages.delete(0)
164 | end for
165 | end function
166 |
--------------------------------------------------------------------------------
/components/ChatItem.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the ChatItem component
4 | function init() as void
5 | ' Constants
6 | ' Components
7 | m.message = m.top.findNode("message")
8 | m.name = m.top.findNode("name")
9 | m.badges = m.top.findNode("badges")
10 | m.emotes = m.top.findNode("emotes")
11 | m.font_util = m.top.findNode("font_util")
12 | ' Variables
13 | m.emote_indices = invalid
14 | m.emote_index = 0
15 | m.message_characters_removed = 0
16 | m.emote_size = 0
17 | ' Init
18 | init_logging()
19 | ' Events
20 | m.top.observeField("itemContent", "on_item_content_change")
21 | m.font_util.observeField("result", "on_callback")
22 | end function
23 |
24 | ' Handle callback
25 | function on_callback(event as object) as void
26 | callback = event.getData().callback
27 | if callback = "on_text_size"
28 | on_text_size(event)
29 | else if callback = "on_space_size"
30 | on_space_size(event)
31 | else
32 | if callback = invalid
33 | callback = ""
34 | end if
35 | printl(m.WARN, "ChatItem: Unhandled callback: " + callback)
36 | end if
37 | end function
38 |
39 | ' Handle a content change
40 | function on_item_content_change(event as object) as void
41 | message = event.getData().message
42 | if type(message) <> "roAssociativeArray"
43 | return
44 | end if
45 | ' Set name
46 | if message.name = invalid or message.message = invalid
47 | message.name = ""
48 | message.message = ""
49 | end if
50 | m.name.text = clean(message.name)
51 | if len(m.name.text) < 3
52 | m.name.text = "justinfan"
53 | end if
54 | ' Set message
55 | ' The message is at max 500 characters
56 | m.message.text = clean(message.message)
57 | if len(m.message.text) > 80
58 | m.message.font = "font:SmallestSystemFont"
59 | else if len(m.message.text) > 50
60 | m.message.font = "font:SmallSystemFont"
61 | else
62 | m.message.font = "font:MediumSystemFont"
63 | end if
64 | ' Set Color
65 | if message.color <> invalid and message.color <> ""
66 | m.name.color = message.color
67 | else
68 | m.name.color = "0x00ff00"
69 | end if
70 | ' Emotes
71 | add_emotes(message.emotes)
72 | ' Add badges
73 | m.name.width = 400
74 | m.name.translation = [0, 0]
75 | badge_size = 18
76 | name_height = 32
77 | badge_margin = 5
78 | m.badges.removeChildrenIndex(m.badges.getChildCount(), 0)
79 | if type(message.badges) = "roArray"
80 | if message.badges.count() > 0
81 | badges_width = message.badges.count() * badge_size + ((message.badges.count() - 1) * badge_margin)
82 | m.name.width -= badges_width
83 | m.name.translation = [badges_width + badge_margin, 0]
84 | for badge_index = 0 to message.badges.count() - 1
85 | badge = m.badges.createChild("Poster")
86 | badge.uri = message.badges[badge_index]
87 | badge.width = badge_size
88 | badge.height = badge_size
89 | badge.translation = [badge_index * badge_size,
90 | (name_height - badge_size) / 2]
91 | if badge_index > 0
92 | badge.translation = [badge.translation[0] + badge_margin,
93 | badge.translation[1]]
94 | end if
95 | end for
96 | end if
97 | end if
98 | end function
99 |
100 | ' Handle adding emotes for a message
101 | function add_emotes(emotes) as void
102 | ' TODO Ignore on old devices
103 | ' Reset emotes
104 | m.emotes.removeChildrenIndex(m.emotes.getChildCount(), 0)
105 | m.emote_indices = emotes
106 | m.emote_index = 0
107 | m.message_characters_removed = 0
108 | ' Request the size of the space characters
109 | m.font_util.get_size = [" ", m.message.font.size, "on_space_size"]
110 | end function
111 |
112 | ' Handle the size of the emote space being calculated
113 | function on_space_size(event as object) as void
114 | size = event.getData().result.width
115 | m.emote_size = size
116 | add_emote()
117 | end function
118 |
119 | ' Parse one emote and handle removing the text and adding the poster
120 | function add_emote() as void
121 | emotes = m.emote_indices
122 | if type(emotes) = "roArray"
123 | if emotes.count() > m.emote_index
124 | emote = emotes[m.emote_index]
125 | if type(emote) = "roAssociativeArray"
126 | if type(emote.start) = "roInt" and type(emote.end) = "roInt"
127 | ' Remove emote text
128 | 'print "ORIGINAL: " + m.message.text
129 | message = left(m.message.text, emote.start - m.message_characters_removed)
130 | left_message = message
131 | 'print "LEFT: " + left_message
132 | message += " "
133 | message += mid(m.message.text, emote.end - m.message_characters_removed + 2)
134 | m.message.text = message
135 | m.message_characters_removed += emote.end - emote.start + 1 - 2
136 | 'print "EMOTE: " + m.message.text
137 | ' Request text size
138 | m.font_util.get_size = [left_message, m.message.font.size, "on_text_size"]
139 | end if
140 | end if
141 | end if
142 | end if
143 | end function
144 |
145 | ' Handle text size event for the text to the left of the emote
146 | function on_text_size(event as object) as void
147 | size = event.getData().result
148 | if size.width >= m.message.width
149 | ' TODO Handle more than the first line
150 | m.emote_index++
151 | add_emote()
152 | return
153 | end if
154 | emotes = m.emote_indices
155 | if type(emotes) = "roArray"
156 | if emotes.count() > m.emote_index
157 | emote = emotes[m.emote_index]
158 | if type(emote) = "roAssociativeArray"
159 | if type(emote.url, 3) = "roString"
160 | emote = emotes[m.emote_index]
161 | emoteComponent = m.emotes.createChild("Poster")
162 | emoteComponent.uri = emote.url
163 | emoteComponent.loadWidth = m.emote_size
164 | 'emoteComponent.height = m.emote_size
165 | x = size.width
166 | y = m.message.translation[1] + (fix(x / m.message.width) * size.height)
167 | if fix(x / m.message.width) > 0
168 | x -= m.message.width * fix(x / m.message.width)
169 | end if
170 | margin = m.message.font.size * 0.14
171 | emoteComponent.translation = [x + margin, y + margin]
172 | end if
173 | end if
174 | end if
175 | end if
176 | m.emote_index++
177 | add_emote()
178 | end function
--------------------------------------------------------------------------------
/components/HlsUtil.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2018 Rolando Islas. All Rights Reserved.
2 |
3 | ' Get the max quality of video the current roku model will support
4 | function get_max_quality_for_model(quality as string, model as string) as object
5 | quality = ucase(quality)
6 | if right(quality, 1) = "P"
7 | quality = left(quality, len(quality) - 1)
8 | end if
9 | requested_quality = val(quality, 0)
10 | models = get_generic_models_from_specific(model)
11 | for each model_generic in models
12 | for each stream_quality in m.global.twitched_config.stream_qualities
13 | if stream_quality.model = model_generic
14 | printl(m.DEBUG, "Quality found for model: " + model_generic + ". Original: " + model)
15 | stream_quality = limit_stream_quality(stream_quality, requested_quality)
16 | return stream_quality
17 | end if
18 | end for
19 | end for
20 | ' Quality not found in database
21 | ' Send a sensible 720p 30fps 7mbps default quality
22 | return {
23 | id: 0,
24 | model: model,
25 | bitrate: 7000000,
26 | comment: "",
27 | "240p30": true,
28 | "240p60": false,
29 | "480p30": true,
30 | "480p60": false,
31 | "720p30": true,
32 | "720p60": false,
33 | "1080p30": true,
34 | "1080p60": false,
35 | "only_source_60": true
36 | }
37 | end function
38 |
39 | ' Given a model return the generic versions of it in an array
40 | ' At least one will be returned
41 | ' For example: Model 5130X will return:
42 | ' [
43 | ' 5130X, ' Original
44 | ' 5130X, ' Ones removed
45 | ' 5100X, ' Tens removed
46 | ' 5000X ' Hundreds removed
47 | ' ]
48 | function get_generic_models_from_specific(model) as object
49 | models = [model]
50 | model_regex = createObject("roRegex", "([0-9]*).*", "")
51 | match = model_regex.match(model)
52 | if match.count() <> 2 or (type(match[1], 3) <> "roString" and type(match[1], 3) <> "String")
53 | return models
54 | end if
55 | model_int = val(match[1], 0)
56 | if model_int < 1000
57 | return models
58 | end if
59 | thousands = int(model_int mod 10000 / 1000)
60 | hundreds = int(model_int / 100 mod 10)
61 | tens = int(model_int / 10 mod 10)
62 | ones = int(model_int mod 10)
63 | models.push(((thousands * 1000) + (hundreds * 100) + (tens * 10)).toStr() + "X")
64 | models.push(((thousands * 1000) + (hundreds * 100)).toStr() + "X")
65 | models.push((thousands * 1000).toStr() + "X")
66 | return models
67 | end function
68 |
69 | ' Limit stream quality
70 | ' @param stream_quality stream associative array
71 | ' @param limit resolution limit
72 | function limit_stream_quality(stream_quality as object, limit as integer) as object
73 | if limit < 1080
74 | stream_quality.["1080p30"] = false
75 | stream_quality.["1080p60"] = false
76 | end if
77 | if limit < 720
78 | stream_quality.["720p30"] = false
79 | stream_quality.["720p60"] = false
80 | end if
81 | if limit < 480
82 | stream_quality.["480p30"] = false
83 | stream_quality.["480p60"] = false
84 | end if
85 | if limit < 240
86 | stream_quality.["240p30"] = false
87 | stream_quality.["240p60"] = false
88 | end if
89 | return stream_quality
90 | end function
91 |
92 | ' Get FPS of a playlist
93 | function get_stream_fps(playlist as object) as integer
94 | return get_quality_and_fps(playlist).fps
95 | end function
96 |
97 | ' Return an associative array containing quality and fps fields for a playlist
98 | function get_quality_and_fps(playlist as object) as object
99 | quality_regex = createObject("roRegex", ".*NAME=" + chr(34) +"(\d+)p?(\d*).*" + chr(34) + ".*", "")
100 | quality = 0
101 | fps = 0
102 | if quality_regex.isMatch(playlist.line_one)
103 | groups = quality_regex.match(playlist.line_one)
104 | quality = val(groups[1], 0)
105 | fps = val(groups[2], 0)
106 | if fps = 0
107 | fps = 30
108 | end if
109 | end if
110 | return {
111 | quality: quality,
112 | fps: fps
113 | }
114 | end function
115 |
116 | ' Get stream quality
117 | function get_stream_quality(playlist as object) as integer
118 | return get_quality_and_fps(playlist).quality
119 | end function
120 |
121 | ' Check if the playlist if of the passed quality or lower
122 | ' @return true if the quality is the same or lower
123 | function is_stream_quality_or_lower(playlist as object, quality as integer) as boolean
124 | quality_regex = createObject("roRegex", ".*NAME=" + chr(34) +"(\d+)p?(\d*).*" + chr(34) + ".*", "")
125 | stream_quality = 0
126 | if quality_regex.isMatch(playlist.line_one)
127 | groups = quality_regex.match(playlist.line_one)
128 | stream_quality = val(groups[1], 0)
129 | end if
130 | return quality > 0 and stream_quality <= quality
131 | end function
132 |
133 | ' Get the bitrate of a playlist
134 | function get_stream_bitrate(playlist as object) as integer
135 | bitrate_regex = createObject("roRegex", ".*BANDWIDTH=(\d+),.*", "")
136 | bitrate = 0
137 | if bitrate_regex.isMatch(playlist.line_two)
138 | groups = bitrate_regex.match(playlist.line_two)
139 | bitrate = val(groups[1], 0)
140 | end if
141 | return bitrate
142 | end function
143 |
144 | ' Check if the playlist is a video
145 | function is_stream_video(playlist as object) as boolean
146 | return instr(0, playlist.line_one, "audio") = 0 and instr(0, playlist.line_two, "audio") = 0
147 | end function
148 |
149 | ' Check if the playlist is a source stream
150 | function is_stream_source(playlist as object) as boolean
151 | return instr(0, playlist.line_one, "source") > 0
152 | end function
153 |
154 | ' Convert the playlist associative array to an array
155 | function stream_to_array(playlist as object) as object
156 | return [
157 | playlist.line_one,
158 | playlist.line_two,
159 | playlist.line_three
160 | ]
161 | end function
162 |
163 | ' Check if a stream playlist if of lesser or equal quality to the one passed
164 | function stream_meets_quality(max_quality as object, stream as object) as boolean
165 | ' Check if this stream is 60 FPS. If it is and the StreamQuality denies
166 | ' non-source 60 FPS, check if it is a source stream.
167 | if get_stream_fps(stream) = 60 and max_quality.only_source_60 and not is_stream_source(stream)
168 | return false
169 | end if
170 | ' Check if the bitrate is higher than the defined max bitrate
171 | if get_stream_bitrate(stream) > max_quality.bitrate
172 | return false
173 | end if
174 | ' Check 30 FPS streams
175 | if get_stream_fps(stream) = 30
176 | if get_stream_quality(stream) <= 240 and not max_quality["240p30"]
177 | return false
178 | end if
179 | if get_stream_quality(stream) > 240 and get_stream_quality(stream) <= 480 and not max_quality["480p30"]
180 | return false
181 | end if
182 | if get_stream_quality(stream) = 720 and not max_quality["720p30"]
183 | return false
184 | end if
185 | if get_stream_quality(stream) > 720 and not max_quality["1080p30"]
186 | return false
187 | end if
188 | end if
189 | ' Check 60 FPS streams
190 | if get_stream_fps(stream) = 60
191 | if get_stream_quality(stream) <= 240 and not max_quality["240p60"]
192 | return false
193 | end if
194 | if get_stream_quality(stream) > 240 and get_stream_quality(stream) <= 480 and not max_quality["480p60"]
195 | return false
196 | end if
197 | if get_stream_quality(stream) = 720 and not max_quality["720p60"]
198 | return false
199 | end if
200 | if get_stream_quality(stream) > 720 and not max_quality["1080p60"]
201 | return false
202 | end if
203 | end if
204 | return true
205 | end function
206 |
--------------------------------------------------------------------------------
/components/Search.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the Search component
4 | function init() as void
5 | ' Constants
6 | m.top.VIDEO = 0
7 | m.top.CHANNEL = 1
8 | m.top.GAME = 2
9 | ' Components
10 | m.keyboard = m.top.findNode("keyboard")
11 | m.search = m.keyboard.textEditBox
12 | m.buttons = m.top.findNode("search_buttons")
13 | m.history = m.top.findNode("history")
14 | m.registry = m.top.findNode("registry")
15 | m.history_title = m.top.findNode("history_title")
16 | ' Var
17 | m.buttons.buttons = [
18 | tr("button_search"),
19 | tr("button_search_channels"),
20 | tr("button_search_games")
21 | ]
22 | m.history_array = []
23 | m.history_title.text = tr("title_search_history")
24 | ' Events
25 | m.top.observeField("visible", "on_set_visible")
26 | m.top.observeField("focus", "on_set_visible")
27 | m.search.observeField("text", "on_text_changed")
28 | m.buttons.observeField("buttonSelected", "on_button_selected")
29 | m.registry.observeField("result", "on_callback")
30 | m.history.observeField("itemSelected", "on_history_item_selected")
31 | ' Init
32 | load_history()
33 | end function
34 |
35 | ' Handle callback
36 | function on_callback(event as object) as void
37 | callback = event.getData().callback
38 | if callback = "on_history_read"
39 | on_history_read(event)
40 | else if callback = "on_history_write"
41 | on_history_write(event)
42 | else
43 | if callback = invalid
44 | callback = ""
45 | end if
46 | printl(m.WARN, "Search: Unhandled callback: " + callback)
47 | end if
48 | end function
49 |
50 | ' Handle keys
51 | function onKeyEvent(key as string, press as boolean) as boolean
52 | print("Search Key: " + key + " Press: " + press.toStr())
53 | ' Keyboard
54 | if m.keyboard.isInFocusChain()
55 | ' Move to buttons
56 | if press and key = "down"
57 | m.buttons.setFocus(true)
58 | return true
59 | ' Move to history
60 | else if press and key = "right" and has_history()
61 | m.history.setFocus(true)
62 | return true
63 | end if
64 | ' Buttons
65 | else if m.buttons.isInFocusChain()
66 | ' Move to keyboard
67 | if press and key = "up"
68 | m.keyboard.setFocus(true)
69 | return true
70 | ' Move to history
71 | else if press and key = "right" and has_history()
72 | m.history.setFocus(true)
73 | return true
74 | end if
75 | ' History
76 | else if m.history.isInFocusChain()
77 | ' Move to keyboard
78 | if press and key = "left"
79 | m.keyboard.setFocus(true)
80 | return true
81 | end if
82 | ' Ignore OK events
83 | else if key = "OK"
84 | return true
85 | end if
86 | return false
87 | end function
88 |
89 | ' Check if the history list has items
90 | function has_history() as boolean
91 | return m.history.content <> invalid and m.history.content.getChildCount() > 0
92 | end function
93 |
94 | ' Check for visibility and focus the keyboard
95 | function on_set_visible(event as object) as void
96 | if event.getData()
97 | if event.getField() = "focus"
98 | m.keyboard.setFocus(true)
99 | load_history()
100 | end if
101 | end if
102 | end function
103 |
104 | ' Load history and populate label list
105 | function load_history() as void
106 | m.registry.read = [m.global.REG_HISTORY, m.global.REG_SEARCH, "on_history_read"]
107 | end function
108 |
109 | ' Handle history data
110 | function on_history_read(event as object) as void
111 | m.history.content = createObject("roSGNode", "ContentNode")
112 | m.history_array = []
113 | history_string = event.getData().result
114 | if type(history_string, 3) = "roString"
115 | history = parseJson(history_string)
116 | if type(history) = "roArray"
117 | m.history_array = history
118 | for each history_item in history
119 | if type(history_item) = "roAssociativeArray"
120 | item = m.history.content.createChild("ContentNode")
121 | item.title = ""
122 | if history_item.query <> invalid
123 | item.title += history_item.query
124 | end if
125 | query_type_string = invalid
126 | query_type = history_item.query_type
127 | if query_type = m.top.VIDEO
128 | query_type_string = "streams"
129 | ' Channel
130 | else if query_type = m.top.CHANNEL
131 | query_type_string = "channels"
132 | ' Game
133 | else if query_type = m.top.GAME
134 | query_type_string = "games"
135 | ' Unhandled
136 | else
137 | print "Unhandled search type: " + query_type.toStr()
138 | end if
139 | if query_type_string <> invalid
140 | item.hdlistitemiconurl = "pkg:/locale/default/images/history_label_list_icon_" + query_type_string + ".png"
141 | item.hdlistitemiconselectedurl = item.hdlistitemiconurl
142 | end if
143 | end if
144 | end for
145 | item = m.history.content.createChild("ContentNode")
146 | item.title = " " + tr("title_search_history_clear")
147 | if type(history) = "roArray" and history.count() = 0
148 | item.title = " " + tr("message_no_data")
149 | end if
150 | end if
151 | end if
152 | end function
153 |
154 | ' Handle search text change
155 | function on_text_changed(event as object) as void
156 | ' TODO search suggestions
157 | end function
158 |
159 | ' Handle a button selection
160 | ' Set a search field to an array [integer type_of_search, string terms]
161 | function on_button_selected(event as object) as void
162 | if len(m.search.text) <= 0 then return
163 | button = event.getData()
164 | m.top.setField("search", [button, m.search.text])
165 | save_search_to_history(button, m.search.text)
166 | end function
167 |
168 | ' Save a search to the history
169 | function save_search_to_history(query_type as integer, query as string) as void
170 | if type(m.history_array) <> "roArray"
171 | return
172 | end if
173 | history = m.history_array
174 | for history_index = 0 to history.count() - 1
175 | history_item = history[history_index]
176 | if type(history_item) = "roAssociativeArray" and history_item.query = query and history_item.query_type = query_type
177 | history.delete(history_index)
178 | history_index = history.count()
179 | end if
180 | end for
181 | history.unshift({
182 | query: query,
183 | query_type: query_type
184 | })
185 | while history.count() > 10
186 | history.pop()
187 | end while
188 | m.history_array = history
189 | historyJsonString = formatJson(history)
190 | m.registry.write = [m.global.REG_HISTORY, m.global.REG_SEARCH,
191 | historyJsonString, "on_history_write"]
192 | end function
193 |
194 | ' Handle history written event
195 | function on_history_write(event as object) as void
196 | load_history()
197 | end function
198 |
199 | ' Handle history item being selected
200 | function on_history_item_selected(event as object) as void
201 | selected_index = event.getData()
202 | if selected_index < 0 or m.history_array = invalid
203 | return
204 | end if
205 | if selected_index >= m.history_array.count()
206 | if selected_index = m.history_array.count()
207 | clear_history()
208 | else
209 | return
210 | end if
211 | end if
212 | history_item = m.history_array[selected_index]
213 | if type(history_item) = "roAssociativeArray"
214 | m.top.setField("search", [history_item.query_type, history_item.query])
215 | save_search_to_history(history_item.query_type, history_item.query)
216 | end if
217 | end function
218 |
219 | ' Clear recent history
220 | function clear_history() as void
221 | m.registry.write = [m.global.REG_HISTORY, m.global.REG_SEARCH, "[]",
222 | "on_history_write"]
223 | end function
--------------------------------------------------------------------------------
/components/Ads.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017-2018 Rolando Islas. All Rights Reserved.
2 |
3 | #if enable_ads
4 |
5 | Library "Roku_Ads.brs"
6 |
7 | ' Ads entry point
8 | function init() as void
9 | ' Constants
10 | m.PORT = createObject("roMessagePort")
11 | ' Ads
12 | m.ads = Roku_Ads()
13 | m.ads.enableNielsenDar(true)
14 | m.ads.enableAdMeasurements(true)
15 | m.ads.setNielsenAppId(m.global.secret.ad_nielsen_id)
16 | m.ads.setAdPrefs(true, 2)
17 | ' Components
18 | m.twitch_api = m.top.findNode("twitch_api")
19 | ' Events
20 | m.top.observeField("show_ads", m.PORT)
21 | m.twitch_api.observeField("result", m.PORT)
22 | ' Init
23 | init_logging()
24 | m.twitch_api.get_ad_server = "on_ad_server"
25 | ' Variables
26 | m.did_fetch_server = false
27 | m.ad_url = ""
28 | m.ad_play_times = {}
29 | ' Task init
30 | m.top.functionName = "run"
31 | m.top.control = "RUN"
32 | end function
33 |
34 | ' Set the ad url of the Roku_Ads instance
35 | function set_ad_url(ad_url as string) as void
36 | m.ads.setAdUrl(ad_url.replace("ROKU_ADS_TRACKING_ID_OBEY_LIMIT", get_ad_id()))
37 | end function
38 |
39 | ' Handle ad server request data from Twitched's API
40 | function on_ad_server(event as object) as void
41 | ad_server = event.getData().result
42 | if type(ad_server) <> "roAssociativeArray" or (type(ad_server.ad_server) <> "roString" and type(ad_server.ad_server) <> "String")
43 | printl(m.DEBUG, "Ads: Failed to fetch ad server from Twitched API")
44 | return
45 | end if
46 | printl(m.DEBUG, "Ads: Fetched ad server from Twitched API")
47 | m.did_fetch_server = true
48 | m.ad_url = ad_server.ad_server
49 | set_ad_url(ad_server.ad_server)
50 | end function
51 |
52 | ' Get the ad id for the device, obeying limited ad tracking
53 | function get_ad_id() as string
54 | ad_id = ""
55 | device_info = createObject("roDeviceInfo")
56 | if not device_info.isRidaDisabled()
57 | ad_id = device_info.getRida()
58 | end if
59 | return ad_id
60 | end function
61 |
62 | ' Main task function
63 | function run() as void
64 | printl(m.DEBUG, "Ads: Ads task started")
65 | while true
66 | msg = wait(0, m.PORT)
67 | ' Field event
68 | if type(msg) = "roSGNodeEvent"
69 | if msg.getField() = "show_ads"
70 | show_ads(msg.getData())
71 | else if msg.getField() = "result"
72 | on_callback(msg)
73 | end if
74 | end if
75 | end while
76 | end function
77 |
78 | ' Handle callback
79 | function on_callback(event as object) as void
80 | callback = event.getData().callback
81 | if callback = "on_ad_server"
82 | on_ad_server(event)
83 | else
84 | if callback = invalid
85 | callback = ""
86 | end if
87 | printl(m.WARN, "on_callback: Unhandled callback: " + callback)
88 | end if
89 | end function
90 |
91 | ' Async show ads call
92 | ' Sets the status to the result of the ad call
93 | ' @param params roArray [nielsen_id as string, genre as string,
94 | ' content_length as integer, is_vod as boolean]
95 | function show_ads(params as object) as void
96 | m.top.showing_ads = true
97 | if not m.did_fetch_server
98 | m.twitch_api.get_ad_server = "on_ad_server"
99 | end if
100 | nielsen_id = params[0]
101 | genre = params[1]
102 | content_length = params[2]
103 | is_vod = params[3]
104 |
105 | ' Check last ad time for a channel
106 | if does_channel_have_ad_cooldown(nielsen_id)
107 | m.top.setField("status", true)
108 | m.top.showing_ads = false
109 | return
110 | end if
111 |
112 | ' Normal ad flow
113 | set_ad_url(m.ad_url)
114 | m.ads.setNielsenProgramId(nielsen_id) ' Streamer
115 | m.ads.setNielsenGenre(genre) ' General variety
116 | m.ads.setContentId(nielsen_id)
117 | m.ads.setContentGenre("Entertainment")
118 | if content_length > 0
119 | m.ads.setContentLength(content_length) ' Seconds
120 | else
121 | m.ads.setContentLength() ' Clear
122 | end if
123 | max_ads = m.global.twitched_config.ad_limit_stream
124 | if is_vod
125 | max_ads = m.global.twitched_config.ad_limit_vod
126 | end if
127 | ads = get_ads(max_ads)
128 | ad_pods = ads["ad_pods"]
129 | ad_count = ads["count"]
130 | track_ads(ad_count, false)
131 | if ad_count = 0
132 | printl(m.DEBUG, "Ads: No ads loaded from third-party ad server")
133 | ' Load Roku ads as a fallback
134 | set_ad_url("")
135 | ads = get_ads(max_ads)
136 | ad_pods = ads["ad_pods"]
137 | ad_count = ads["count"]
138 | track_ads(ad_count, true)
139 | if ad_count = 0
140 | printl(m.DEBUG, "Ads: No ads loaded from Roku ad server")
141 | m.top.setField("status", true)
142 | return
143 | end if
144 | end if
145 | printl(m.DEBUG, "Ads: Showing " + ad_count.toStr() + " ads")
146 | did_ads_play = m.ads.showAds(ad_pods, invalid, m.top.view)
147 | if did_ads_play
148 | m.ad_play_times[nielsen_id] = uptime(0)
149 | end if
150 | m.top.setField("status", did_ads_play)
151 | m.top.showing_ads = false
152 | end function
153 |
154 | ' Returns true if the channel has played an ad in the last 5 minutes.
155 | ' An ad should not be played if this is the case.
156 | function does_channel_have_ad_cooldown(key as string) as boolean
157 | ' Clean old times
158 | to_delete = []
159 | for each time_key in m.ad_play_times.keys()
160 | if uptime(0) - m.ad_play_times[time_key] >= 5 * 60
161 | to_delete.push(time_key)
162 | end if
163 | end for
164 | for each time_key in to_delete
165 | m.ad_play_times.delete(time_key)
166 | end for
167 | ' Check time
168 | return m.ad_play_times.doesExist(key)
169 | end function
170 |
171 | ' Get the ads from the current ad server.
172 | ' @return associative array containing ad pods and the total ad count
173 | ' The ad_pods array may be invalid
174 | ' {
175 | ' ad_pods: [...],
176 | ' count: 0
177 | ' }
178 | ' An array of ad pods will be returned with the most ads contained total equal
179 | ' to the total allowed per ad bread, passed as the first argument.
180 | function get_ads(max_ads as integer) as object
181 | if max_ads < 1
182 | printl(m.DEBUG, "Ads: Max ads set to less than 1. Forcing 2")
183 | max_ads = 2 ' Load two if for some reason max_ads is 0 or less
184 | end if
185 | ret_ad_pods = []
186 | ad_count = 0
187 | ad_pods = m.ads.getAds()
188 | if type(ad_pods) <> "roArray"
189 | return {
190 | ad_pods: invalid,
191 | count: 0
192 | }
193 | end if
194 | for each ad_pod in ad_pods
195 | if type(ad_pod) = "roAssociativeArray" and ad_count < max_ads
196 | allowed_ads = []
197 | duration = 0
198 | ads = ad_pod["ads"]
199 | ' Populate an array of ads for this pod
200 | if type(ads) = "roArray"
201 | max_index = min(max_ads - ad_count - 1, ads.count() - 1)
202 | for ad_index = 0 to max_index
203 | ad = ads[ad_index]
204 | ad_duration = ad["duration"]
205 | if ad_duration <> invalid
206 | duration = duration + ad_duration
207 | allowed_ads.push(ad)
208 | end if
209 | end for
210 | end if
211 | ' Add this pod to the ad pods to return
212 | if allowed_ads.count() > 0
213 | ad_pod["ads"] = allowed_ads
214 | ad_pod["duration"] = duration
215 | ad_count = ad_count + allowed_ads.count()
216 | ret_ad_pods.push(ad_pod)
217 | end if
218 | end if
219 | end for
220 | ' Return ad pods
221 | if ad_count > 0
222 | return {
223 | ad_pods: ret_ad_pods,
224 | count: ad_count
225 | }
226 | end if
227 | return {
228 | ad_pods: invalid,
229 | count: 0
230 | }
231 | end function
232 |
233 | ' Send analytics data about how many ads were received for playback
234 | function track_ads(ads_count as integer, from_roku as boolean) as void
235 | m.global.analytics.trackEvent = {
236 | google: {
237 | ec: "Ad",
238 | ea: "Ads Started",
239 | el: "Count: " + ads_count.toStr() + ", From Roku: " + from_roku.toStr()
240 | }
241 | }
242 | end function
243 |
244 | #else
245 |
246 | function init() as void
247 | end function
248 |
249 | #end if
250 |
--------------------------------------------------------------------------------
/components/Settings.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the InfoScreen component
4 | function init() as void
5 | ' Constants
6 | m.URL_INFO = "https://twitched.org/info"
7 | m.URL_OSS = "https://twitched.org/info/oss"
8 | m.URL_PRIVACY = "https://twitched.org/info/privacy"
9 | m.INFO = 6
10 | m.OSS = 7
11 | m.PRIVACY = 8
12 | m.LANGUAGE = 0
13 | m.QUALITY = 1
14 | m.LOG_IN_OUT = 5
15 | m.HLS_LOCAL = 3
16 | m.CHAT_DELAY = 4
17 | m.START_MENU = 2
18 | m.MENU_ITEMS = ["title_language", "title_quality", "title_start_menu",
19 | "title_hls_local", "title_chat_delay", "title_log_in_out", "title_info",
20 | "title_oss", "title_privacy_policy"
21 | ]
22 | m.LANG_JSON = parseJson(readAsciiFile("pkg:/resources/twitch_lang.json"))
23 | ' Components
24 | m.menu = m.top.findNode("menu")
25 | m.title = m.top.findNode("title")
26 | m.message = m.top.findNode("message")
27 | m.checklist = m.top.findNode("checklist")
28 | m.radiolist = m.top.findNode("radiolist")
29 | ' Init
30 | m.initial_radio_list_position = m.radiolist.translation
31 | m.focused_menu_item = -1
32 | init_menu()
33 | ' Events
34 | m.top.observeField("visible", "on_set_visible")
35 | m.top.observeField("focus", "on_set_visible")
36 | m.menu.observeField("itemSelected", "on_menu_item_selected")
37 | m.menu.observeField("itemFocused", "on_menu_item_focused")
38 | m.checklist.observeField("checkedState", "on_checked_state_update")
39 | m.radiolist.observeField("checkedItem", "on_checked_Item_update")
40 | end function
41 |
42 | ' Handle keys
43 | function onKeyEvent(key as string, press as boolean) as boolean
44 | ' Checklist / Radiolist
45 | if m.checklist.hasFocus() or m.radiolist.hasFocus()
46 | ' Set main settings menu focus
47 | if press and (key = "left" or key = "back")
48 | m.menu.setFocus(true)
49 | return true
50 | end if
51 | ' Menu
52 | else if m.menu.hasFocus()
53 | ' Activate
54 | if press and key = "right"
55 | if m.menu.itemFocused = m.LANGUAGE
56 | m.checklist.setFocus(true)
57 | return true
58 | else if m.menu.itemFocused = m.QUALITY
59 | m.radiolist.setFocus(true)
60 | return true
61 | else if m.menu.itemFocused = m.HLS_LOCAL
62 | m.radiolist.setFocus(true)
63 | return true
64 | else if m.menu.itemFocused = m.START_MENU
65 | m.radiolist.setFocus(true)
66 | return true
67 | else if m.menu.itemFocused = m.CHAT_DELAY
68 | m.radiolist.setFocus(true)
69 | return true
70 | end if
71 | end if
72 | end if
73 | return false
74 | end function
75 |
76 | ' Check for visibility and focus the menu
77 | function on_set_visible(event as object) as void
78 | if event.getData()
79 | reset()
80 | if event.getField() = "focus"
81 | m.menu.setFocus(true)
82 | end if
83 | m.menu.jumpToItem = 0
84 | ' Add log in/out item
85 | log_in_out = m.menu.content.getChild(m.LOG_IN_OUT)
86 | if m.top.getField("authenticated")
87 | log_in_out.title = " " + tr("title_log_out")
88 | else
89 | log_in_out.title = " " + tr("title_log_in")
90 | end if
91 | end if
92 | end function
93 |
94 | ' Handle menu item selected
95 | function on_menu_item_selected(event as object) as void
96 | select_menu_item(event.getData())
97 | end function
98 |
99 | ' Handle menu item focus
100 | function on_menu_item_focused(event as object) as void
101 | focus_menu_item(event.getData())
102 | end function
103 |
104 | ' Select a menu item
105 | function select_menu_item(item as integer) as void
106 | ' Info
107 | if item = m.INFO
108 | ' OSS
109 | else if item = m.OSS
110 | ' Privacy Policy
111 | else if item = m.PRIVACY
112 | ' Language
113 | else if item = m.LANGUAGE
114 | m.checklist.setFocus(true)
115 | ' Quality
116 | else if item = m.QUALITY
117 | m.radiolist.setFocus(true)
118 | ' Log in/out
119 | else if item = m.LOG_IN_OUT
120 | ' Sign out
121 | if m.top.getField("authenticated")
122 | m.top.setField("sign_out_in", "out")
123 | ' Sign in
124 | else
125 | m.top.setField("sign_out_in", "in")
126 | end if
127 | ' Local HLS
128 | else if item = m.HLS_LOCAL
129 | m.radiolist.setFocus(true)
130 | ' Chat Delay
131 | else if item = m.CHAT_DELAY
132 | m.radiolist.setFocus(true)
133 | ' Start menu
134 | else if item = m.START_MENU
135 | m.radiolist.setFocus(true)
136 | ' Unhandled
137 | else
138 | print "Unhandled setting menu item selected: " + item.toStr()
139 | end if
140 | end function
141 |
142 | ' Focus a menu item
143 | function focus_menu_item(item as integer) as void
144 | reset()
145 | m.focused_menu_item = item
146 | ' Info
147 | if item = m.INFO
148 | m.title.text = tr("title_info")
149 | m.message.text = tr("message_settings_info").replace("{1}", m.URL_INFO)
150 | ' OSS
151 | else if item = m.OSS
152 | m.title.text = tr("title_oss")
153 | m.message.text = tr("message_settings_oss").replace("{1}", m.URL_OSS)
154 | ' Privacy Policy
155 | else if item = m.PRIVACY
156 | m.title.text = tr("title_privacy_policy")
157 | m.message.text = tr("message_settings_privacy_policy").replace("{1}", m.URL_PRIVACY)
158 | ' Language
159 | else if item = m.LANGUAGE
160 | checked_state = []
161 | ' Set title
162 | m.title.text = tr("title_language")
163 | ' Clear content
164 | m.checklist.content.removeChildrenIndex(m.checklist.content.getChildCount(), 0)
165 | ' Add lang items
166 | for each lang_item in m.LANG_JSON
167 | lang_enabled = false
168 | for each lang in m.global.language
169 | if lang_item.code = lang
170 | lang_enabled = true
171 | end if
172 | end for
173 | checked_state.push(lang_enabled)
174 | ' Add lang to checklist
175 | check_item = m.checklist.content.createChild("ContentNode")
176 | name = clean(lang_item.name)
177 | if len(name) <> len(lang_item.name)
178 | name = lang_item.name_en
179 | end if
180 | check_item.title = name
181 | check_item.hideicon = false
182 | end for
183 | ' Set checklist state
184 | m.checklist.checkedState = checked_state
185 | m.checklist.visible = true
186 | ' Quality
187 | else if item = m.QUALITY
188 | ' Set title
189 | m.title.text = tr("title_video_quality")
190 | ' Clear content
191 | m.radiolist.content.removeChildrenIndex(m.radiolist.content.getChildCount(), 0)
192 | ' Add quality items
193 | items = ["title_automatic", "1080p", "720p", "480p", "360p", "240p"]
194 | for each quality in items
195 | radio_item = m.radiolist.content.createChild("ContentNode")
196 | radio_item.title = tr(quality)
197 | end for
198 | ' Set selected item
199 | if m.top.quality = "auto"
200 | m.radiolist.checkedItem = 0
201 | else
202 | for quality = 0 to items.count() - 1
203 | if m.top.quality = items[quality]
204 | m.radiolist.checkedItem = quality
205 | end if
206 | end for
207 | end if
208 | ' Show radio list
209 | m.radiolist.visible = true
210 | ' Chat delay
211 | else if item = m.CHAT_DELAY
212 | ' Title
213 | m.title.text = tr("title_chat_delay")
214 | m.message.text = tr("message_chat_delay")
215 | ' Clear
216 | m.radiolist.content.removeChildrenIndex(m.radiolist.content.getChildCount(), 0)
217 | items = ["title_enabled", "title_disabled"]
218 | for each state in items
219 | radio_item = m.radiolist.content.createChild("ContentNode")
220 | radio_item.title = tr(state)
221 | end for
222 | m.global.do_delay_chat = (m.radiolist.checkedItem = 0)
223 | ' Select Item
224 | if m.global.do_delay_chat
225 | m.radiolist.checkedItem = 0
226 | else
227 | m.radiolist.checkedItem = 1
228 | end if
229 | ' Translate radio list
230 | trans = m.radiolist.translation
231 | m.radiolist.translation = [trans[0], trans[1] + 250]
232 | m.radiolist.visible = true
233 | ' Log in/out
234 | else if item = m.LOG_IN_OUT
235 | ' Local HLS
236 | else if item = m.HLS_LOCAL
237 | ' Title and message
238 | m.title.text = tr("title_hls_local")
239 | m.message.text = tr("message_hls_local")
240 | ' Clear content
241 | m.radiolist.content.removeChildrenIndex(m.radiolist.content.getChildCount(), 0)
242 | ' Add quality items
243 | items = ["title_enabled", "title_disabled"]
244 | for each state in items
245 | radio_item = m.radiolist.content.createChild("ContentNode")
246 | radio_item.title = tr(state)
247 | end for
248 | ' Set selected item
249 | if m.global.use_local_hls_parsing
250 | m.radiolist.checkedItem = 0
251 | else
252 | m.radiolist.checkedItem = 1
253 | end if
254 | ' Move radio list down
255 | trans = m.radiolist.translation
256 | m.radiolist.translation = [trans[0], trans[1] + 250]
257 | ' Show radio list
258 | m.radiolist.visible = true
259 | ' Start Menu
260 | else if item = m.START_MENU
261 | ' Title and text
262 | m.title.text = tr("title_start_menu")
263 | m.message.text = tr("message_start_menu")
264 | ' Clear
265 | m.radiolist.content.removeChildrenIndex(m.radiolist.content.getChildCount(), 0)
266 | ' Add menu items
267 | items = ["title_popular", "title_games",
268 | "title_communities", "title_followed", "title_search"]
269 | for each menu_title in items
270 | radio_item = m.radiolist.content.createChild("ContentNode")
271 | radio_item.title = tr(menu_title)
272 | end for
273 | ' Set the selected item
274 | m.radiolist.checkedItem = m.global.start_menu_index
275 | ' Move radio list down
276 | trans = m.radiolist.translation
277 | m.radiolist.translation = [trans[0], trans[1] + 125]
278 | ' Show
279 | m.radiolist.visible = true
280 | ' Unhandled
281 | else
282 | print "Unhandled setting menu item focused: " + item.toStr()
283 | end if
284 | end function
285 |
286 | ' Reset the title and message
287 | function reset() as void
288 | m.title.text = ""
289 | m.message.text = ""
290 | m.checklist.visible = false
291 | m.radiolist.visible = false
292 | m.radiolist.translation = m.initial_radio_list_position
293 | m.focused_menu_item = -1
294 | end function
295 |
296 | ' Initialize the settings panel list
297 | function init_menu() as void
298 | ' Add items
299 | for each title in m.MENU_ITEMS
300 | item = m.menu.content.createChild("ContentNode")
301 | item.title = " " + tr(title)
302 | end for
303 | end function
304 |
305 | ' Handle checklist checked state change
306 | function on_checked_state_update(event as object) as void
307 | if m.LANG_JSON.count() <> m.checklist.checkedState.count()
308 | return
309 | end if
310 | ' Check if all was selected prior to modification
311 | all_selected = false
312 | for each lang in m.global.language
313 | if lang = "all"
314 | all_selected = true
315 | end if
316 | end for
317 | ' Uncheck others if all is selected
318 | if m.checklist.checkedState[0] and not all_selected
319 | checkedState = [true]
320 | for index = 1 to m.LANG_JSON.count() - 1
321 | checkedState[index] = false
322 | end for
323 | m.checklist.checkedState = checkedState
324 | else if all_selected
325 | checkedState = m.checklist.checkedState
326 | checkedState[0] = false
327 | m.checklist.checkedState = checkedState
328 | end if
329 | ' Create list of enabled languages
330 | language = []
331 | for index = 0 to m.LANG_JSON.count() - 1
332 | if m.checklist.checkedState[index]
333 | language.push(m.LANG_JSON[index].code)
334 | end if
335 | end for
336 | m.top.setField("language", language)
337 | end function
338 |
339 | ' Handle radiolist checked item change
340 | function on_checked_item_update(event as object) as void
341 | if m.focused_menu_item = m.QUALITY
342 | if event.getData() = 0
343 | m.top.setField("quality", "auto")
344 | else if event.getData() > -1
345 | m.top.setField("quality", m.radiolist.content.getChild(event.getData()).title)
346 | end if
347 | else if m.focused_menu_item = m.HLS_LOCAL
348 | if event.getData() = 0
349 | m.top.setField("hls_local", true)
350 | else
351 | m.top.setField("hls_local", false)
352 | end if
353 | else if m.focused_menu_item = m.START_MENU
354 | if event.getData() > -1
355 | m.top.setField("start_menu_index", event.getData())
356 | end if
357 | else if m.focused_menu_item = m.CHAT_DELAY
358 | m.top.setField("delay_chat", event.getData() = 0)
359 | end if
360 | end function
361 |
--------------------------------------------------------------------------------
/components/Irc.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the Irc component
4 | function init() as void
5 | ' Constants
6 | m.PORT = createObject("roMessagePort")
7 | params_regex = "(\s(?:(?::(.*))|(?:([^\s]+))))"
8 | twitch_tag_regex = "([^\s;=]+=[^\s;]*)"
9 | twitch_tags_regex = "(?:@((?:$TWITCH_TAG;?)+)\s)?".replace("$TWITCH_TAG", twitch_tag_regex)
10 | m.PARAMS_REGEX = createObject("roRegex", params_regex, "")
11 | m.TWITCH_TAG_REGEX = createObject("roRegex", twitch_tag_regex, "")
12 | m.TWITCH_TAGS_REGEX = createObject("roRegex", twitch_tags_regex, "")
13 | m.MESSAGE_REGEX = createObject("roRegex", "^$TWITCH_TAGS(?::([^!@\s]+)(?:!([^@\s]+))?(?:@([^\s]+))?\s)?([A-Za-z0-9]+)($PARAMS+)?(?:\r?\n?)".replace("$PARAMS", params_regex).replace("$TWITCH_TAGS", twitch_tags_regex), "")
14 | m.NEW_LINE_REGEX = createObject("roRegex", chr(13) + "?" + chr(10), "")
15 | m.EMOTE_URL = "http://static-cdn.jtvnw.net/emoticons/v1/$ID/$SIZE"
16 | ' IRC Constants
17 | m.IRC_HOST_NAME = "irc.chat.twitch.tv"
18 | m.IRC_PORT = 6667
19 | m.PASS = "PASS"
20 | m.NICK = "NICK"
21 | m.JOIN = "JOIN"
22 | m.PING = "PING"
23 | m.PONG = "PONG"
24 | m.PRIVMSG = "PRIVMSG"
25 | m.PART = "PART"
26 | m.MOTD_START = "RPL_MOTDSTART"
27 | m.MOTD_START_D = "375"
28 | m.MOTD = "RPL_MOTD"
29 | m.MOTD_D = "372"
30 | m.MOTD_END = "RPL_ENDOFMOTD"
31 | m.MOTD_END_D = "376"
32 | m.CAP = "CAP"
33 | m.NOTICE = "NOTICE"
34 | m.BUFFER_SIZE = 100000
35 | ' Components
36 | m.twitch_api = m.top.findNode("twitch_api")
37 | ' Variables
38 | m.channel = ""
39 | m.socket = invalid
40 | m.data = createObject("roByteArray")
41 | m.data[m.BUFFER_SIZE] = 0
42 | m.data_size = 0
43 | m.badges = {}
44 | m.connected = false
45 | ' Init
46 | init_logging()
47 | ' Events
48 | m.top.observeField("connect", m.PORT)
49 | m.top.observeField("disconnect", m.PORT)
50 | m.top.observeField("send_chat_message", m.PORT)
51 | m.twitch_api.observeField("result", m.PORT)
52 | ' Task init
53 | m.top.functionName = "run"
54 | m.top.control = "RUN"
55 | end function
56 |
57 | ' Handle callback
58 | function on_callback(event as object) as void
59 | callback = event.getData().callback
60 | if callback = "on_badges"
61 | on_badges(event)
62 | else
63 | if callback = invalid
64 | callback = ""
65 | end if
66 | printl(m.WARN, "on_callback: Unhandled callback: " + callback)
67 | end if
68 | end function
69 |
70 | ' Initialize the socket
71 | function init_socket() as void
72 | ' Create socket
73 | m.socket = createObject("roStreamSocket")
74 | m.socket.notifyReadable(true)
75 | m.socket.notifyWritable(true)
76 | m.socket.notifyException(true)
77 | m.socket.setMessagePort(m.PORT)
78 | ' Set address
79 | twitch_irc = createObject("roSocketAddress")
80 | twitch_irc.setHostName(m.IRC_HOST_NAME)
81 | twitch_irc.setPort(m.IRC_PORT)
82 | m.socket.setSendToAddress(twitch_irc)
83 | end function
84 |
85 | ' Main task loop
86 | function run() as void
87 | printl(m.INFO, "Irc task started")
88 | m.twitch_api.get_badges = ["on_badges"]
89 | while true
90 | ' Check for messages
91 | ' Do not user port.getMessage()!
92 | ' SocketEvents will not show up if it is used.
93 | msg = wait(0, m.PORT)
94 | ' Field event
95 | if type(msg) = "roSGNodeEvent"
96 | if msg.getField() = "connect"
97 | connect(msg.getData())
98 | else if msg.getField() = "disconnect"
99 | disconnect()
100 | else if msg.getField() = "result"
101 | on_callback(msg)
102 | else if msg.getField() = "send_chat_message"
103 | send_chat_message(msg.getData())
104 | end if
105 | ' Socket event
106 | else if type(msg) = "roSocketEvent"
107 | connect_to_server()
108 | read_socket_data()
109 | end if
110 | end while
111 | end function
112 |
113 | ' Read socket data
114 | function read_socket_data() as void
115 | if m.socket = invalid or not m.socket.isReadable()
116 | return
117 | end if
118 | bytes_received = m.socket.receive(m.data, m.data_size, 1024)
119 | if bytes_received < 0
120 | return
121 | end if
122 | m.data_size += bytes_received
123 | m.data[m.data_size] = 0
124 | data = m.data.toAsciiString()
125 | if data <> ""
126 | split = m.NEW_LINE_REGEX.split(data)
127 | if not data.right(1) = chr(10)
128 | data = split[split.count() - 1]
129 | split.delete(split.count() - 1)
130 | else
131 | data = ""
132 | end if
133 | ' Handle the messages
134 | for each message in split
135 | printl(m.EXTRA, "IRC MESSAGE: " + message)
136 | message_parsed = parse_message(message)
137 | if message_parsed <> invalid
138 | handle_message(message_parsed)
139 | end if
140 | end for
141 | end if
142 | m.data.fromAsciiString(data)
143 | m.data_size = m.data.count()
144 | m.data[m.BUFFER_SIZE] = 0
145 | end function
146 |
147 | ' Send connection details to the IRC server
148 | function connect_to_server() as void
149 | if m.connected or m.socket = invalid or not m.socket.isWritable()
150 | return
151 | end if
152 | ' Send initial request
153 | printl(m.INFO, "IRC socket connected")
154 | cmd(m.CAP, ["REQ", "twitch.tv/tags"])
155 | if m.top.getField("token") <> "" and m.top.getField("user_name") <> ""
156 | cmd(m.PASS, "oauth:" + m.top.getField("token"))
157 | end if
158 | if m.top.getField("user_name") <> ""
159 | cmd(m.NICK, m.top.getField("user_name"))
160 | else
161 | cmd(m.NICK, "justinfan" + rnd(&h7fffffff).toStr())
162 | end if
163 | cmd(m.JOIN, "#" + m.streamer)
164 | m.connected = true
165 | end function
166 |
167 | ' Handle connecting
168 | ' A hastag(#) is prefixed to the streamer name
169 | ' If the streamer name is an empty string, this will act as a disconnect call
170 | ' @param streamer channel to connect to
171 | function connect(streamer as string) as void
172 | disconnect()
173 | ' Connect
174 | init_socket()
175 | if m.socket.connect()
176 | printl(m.INFO, "IRC socket connecting")
177 | m.streamer = streamer
178 | else
179 | printl(m.INFO, "Irc socket connection failed")
180 | end if
181 | end function
182 |
183 | ' Disconnect from the channel and the server
184 | function disconnect() as void
185 | if m.socket <> invalid
186 | m.socket.close()
187 | m.socket = invalid
188 | end if
189 | m.channel = ""
190 | m.connected = false
191 | m.streamer = ""
192 | printl(m.INFO, "IRC socket disconnected")
193 | end function
194 |
195 | ' Send a command to the IRC server
196 | function cmd(command as string, args as object) as void
197 | if m.socket = invalid
198 | return
199 | end if
200 | formatted_args = ""
201 | if type(args) = "String" or type(args) = "string" or type(args) = "roString"
202 | args = [args]
203 | end if
204 | for arg_index = 0 to args.count() - 1
205 | if arg_index = args.count() - 1
206 | formatted_args += ":" + args[arg_index]
207 | else
208 | formatted_args += args[arg_index] + " "
209 | end if
210 | end for
211 | printl(m.EXTRA, "IRC COMMAND: " + command + " " + formatted_args)
212 | sent = m.socket.sendStr(command + " " + formatted_args + chr(10))
213 | printl(m.EXTRA, " sent: " + sent.toStr())
214 | end function
215 |
216 | ' Parse a message string
217 | ' @return assocarray of parsed message values or invalid on parse failure
218 | function parse_message(message_string as string) as object
219 | match = m.MESSAGE_REGEX.match(message_string)
220 | if match.count() = 0
221 | return invalid
222 | end if
223 | printl(m.VERBOSE, "========================= COMMAND =========================")
224 | for each group in match
225 | printl(m.VERBOSE, "] " + group)
226 | end for
227 | message = {
228 | twitch_tags: parse_twitch_tags(match[1]),
229 | server_name: match[3],
230 | nick: match[3],
231 | user: match[4],
232 | host: match[5],
233 | command: match[6],
234 | params: parse_params(match[7])
235 | }
236 | return message
237 | end function
238 |
239 | ' Parse Twitch tags into an associative array
240 | function parse_twitch_tags(tags_string as dynamic) as object
241 | tags = {}
242 | if tags_string = invalid or tags_string = ""
243 | return tags
244 | end if
245 | ' No finditer implementation. The TWITCH_TAG_REGEX cannot be used directly
246 | ' for finding the matches. Tags are split at semicolons and equal signs
247 | matches = tags_string.split(";")
248 | if matches.count() < 1
249 | return tags
250 | end if
251 | for each match in matches
252 | printl(m.VERBOSE, "] TAG: " + match)
253 | tag = match.split("=")
254 | tags[tag[0]] = tag[1]
255 | end for
256 | return tags
257 | end function
258 |
259 | ' Parse IRC message params into an array
260 | function parse_params(params_string as dynamic) as object
261 | params = []
262 | if params_string = invalid or params_string = ""
263 | return params
264 | end if
265 | ' Replace the first space
266 | if params_string.instr(" ") = 0
267 | params_string = params_string.mid(1)
268 | end if
269 | ' No finditer implementation. the PARAMS_REGEX cannot be used directly
270 | ' Middle params are replaced by spaces
271 | ' The last params starts with a colon and can have spaces
272 | matches = params_string.split(" ")
273 | ' Find middle params
274 | break = false
275 | for each match in matches
276 | if match.instr(":") <> -1
277 | goto middle_params
278 | end if
279 | params.push(match)
280 | end for
281 | middle_params:
282 | ' Add last param
283 | last_param_split = params_string.split(":")
284 | if last_param_split.count() >= 2
285 | last_param = ""
286 | for param_index = 1 to last_param_split.count() - 1
287 | if param_index > 1
288 | last_param += ":"
289 | end if
290 | last_param += last_param_split[param_index]
291 | end for
292 | params.push(last_param)
293 | end if
294 | ' Print params
295 | for each param in params
296 | printl(m.VERBOSE, "] PARAM: " + param)
297 | end for
298 | return params
299 | end function
300 |
301 | ' Handle a parsed IRC message
302 | ' @param message assocarray of message values
303 | function handle_message(message as object) as void
304 | printl(m.VERBOSE, message)
305 | ' Ping
306 | if message.command = m.PING:
307 | cmd(m.PONG, message.params[0])
308 | ' Notice
309 | else if message.command = m.NOTICE
310 | notice = ""
311 | for each param in message.params
312 | notice += param + " "
313 | end for
314 | printl(m.INFO, "IRC NOTICE: " + notice)
315 | ' Join
316 | else if message.command = m.JOIN
317 | m.channel = message.params[0]
318 | printl(m.INFO, "IRC JOIN: " + m.channel)
319 | chat_message = {
320 | name: tr("twitched"),
321 | message: tr("message_irc_connected"),
322 | color: "#ffffff",
323 | do_not_queue: true
324 | }
325 | m.top.setField("chat_message", chat_message)
326 | ' Part
327 | else if message.command = m.PART
328 | printl(m.INFO, "IRC PART: " + message.params[0])
329 | m.channel = ""
330 | ' Msg
331 | else if message.command = m.PRIVMSG
332 | handle_privmsg(message)
333 |
334 | end if
335 | end function
336 |
337 | ' Parse a message for details and construct a clean message object to
338 | ' set to a global field as a chat event
339 | function handle_privmsg(message as object) as void
340 | name = message.nick
341 | display_name = message.twitch_tags["display-name"]
342 | if display_name <> invalid and display_name <> ""
343 | display_name_clean = clean(display_name)
344 | if len(display_name) = len(display_name_clean)
345 | name = display_name_clean
346 | end if
347 | end if
348 | chat_message = {
349 | name: name,
350 | message: message.params[1],
351 | color: message.twitch_tags.color,
352 | badges: parse_badges(message.twitch_tags.badges),
353 | emotes: parse_emotes(message.twitch_tags.emotes)
354 | }
355 | m.top.setField("chat_message", chat_message)
356 | end function
357 |
358 | ' Parse Twitch badges string
359 | ' @return array of URL strings
360 | function parse_badges(badges_string as string) as object
361 | badges = []
362 | if badges_string = ""
363 | return badges
364 | end if
365 | comma_split = badges_string.split(",")
366 | for each badge_string in comma_split
367 | slash_split = badge_string.split("/")
368 | if slash_split.count() = 2
369 | name = slash_split[0]
370 | version = slash_split[1]
371 | badge = m.badges[name]
372 | if type(badge) = "roAssociativeArray" and type(badge.versions) = "roAssociativeArray" and type(badge.versions[version]) = "roAssociativeArray"
373 | badge_url = badge.versions[version].image_url_1x
374 | if badge_url <> invalid and badge_url <> ""
375 | badges.push(badge_url)
376 | end if
377 | end if
378 | end if
379 | end for
380 | return badges
381 | end function
382 |
383 | ' Parse Twich emotes string
384 | ' @return array of associative arrays
385 | function parse_emotes(emotes_string as string) as object
386 | emotes = []
387 | if emotes_string = "" or emotes_string = invalid
388 | return emotes
389 | end if
390 | emote_split = emotes_string.split("/")
391 | for each emote_string in emote_split
392 | id_index_split = emote_string.split(":")
393 | if id_index_split.count() > 1
394 | emote_id = id_index_split[0]
395 | index_split = id_index_split[1].split(",")
396 | for each index in index_split
397 | start_end_split = index.split("-")
398 | if start_end_split.count() = 2
399 | emote = {
400 | url: m.EMOTE_URL.replace("$ID", emote_id.toStr()).replace("$SIZE", "1.0"),
401 | start: val(start_end_split[0], 0),
402 | end: val(start_end_split[1], 0)
403 | }
404 | emotes.push(emote)
405 | end if
406 | end for
407 | end if
408 | end for
409 | ' Ensure emotes are ordered by their start index
410 | ordered = []
411 | added_size = -1
412 | smallest = invalid
413 | for items = 1 to emotes.count()
414 | for each emote in emotes
415 | if (smallest = invalid or emote.start < smallest.start) and emote.start > added_size
416 | smallest = emote
417 | end if
418 | end for
419 | if smallest <> invalid
420 | ordered.push(smallest)
421 | added_size = smallest.start
422 | smallest = invalid
423 | else
424 | return ordered ' This should not happen.
425 | end if
426 | end for
427 | return ordered
428 | end function
429 |
430 | ' Handle badges data
431 | function on_badges(event as object) as void
432 | badges = event.getData().result
433 | if type(badges) <> "roAssociativeArray" or type(badges.badge_sets) <> "roAssociativeArray"
434 | return
435 | end if
436 | m.badges = badges.badge_sets
437 | end function
438 |
439 | ' Send a chat message to the current channel
440 | function send_chat_message(msg as string) as void
441 | chat_message = {
442 | name: m.top.getField("user_name"),
443 | message: msg
444 | }
445 | m.top.setField("chat_message", chat_message)
446 | cmd(m.PRIVMSG, [m.channel, msg])
447 | end function
448 |
--------------------------------------------------------------------------------
/locale/default/translations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 | title_featured
9 | Featured
10 |
11 |
12 | title_games
13 | Games
14 |
15 |
16 | title_creative
17 | Creative
18 |
19 |
20 | title_communities
21 | Communities
22 |
23 |
24 | title_popular
25 | Popular
26 |
27 |
28 | title_followed
29 | Followed
30 |
31 |
32 | error_api_fail
33 | Failed to connect to the API server.
34 |
35 |
36 | title_community
37 | Community
38 |
39 |
40 | button_play
41 | Play
42 |
43 |
44 | title_stream
45 | Stream
46 |
47 |
48 | prefix_streamer
49 | Streamer
50 |
51 |
52 | prefix_game
53 | Game
54 |
55 |
56 | title_error
57 | Error
58 |
59 |
60 | title_error_code
61 | Error Code
62 |
63 |
64 | title_viewers
65 | Viewers
66 |
67 |
68 | title_language
69 | Language
70 |
71 |
72 | title_uptime
73 | Uptime
74 |
75 |
76 | title_stream_type
77 | Type
78 |
79 |
80 | inline_viewers_singular
81 | viewer
82 |
83 |
84 | inline_viewers_plural
85 | viewers
86 |
87 |
88 | inline_hours_singular
89 | hour
90 |
91 |
92 | inline_hours_plural
93 | hours
94 |
95 |
96 | inline_minutes_singular
97 | minute
98 |
99 |
100 | inline_minutes_plural
101 | minutes
102 |
103 |
104 | title_exit_confirm
105 | Exit
106 |
107 |
108 | message_exit_confirm
109 | Would you like to exit Twitched?
110 |
111 |
112 | button_cancel
113 | Cancel
114 |
115 |
116 | button_confirm
117 | Confirm
118 |
119 |
120 | title_loading_stream
121 | Loading Stream
122 |
123 |
124 | error_stream_not_found
125 | The stream is not live or does not exist.
126 |
127 |
128 | error_deep_link_invalid
129 | Could not launch content: Invalid deep link.
130 |
131 |
132 | message_loading
133 | Loading
134 |
135 |
136 | message_web_link
137 | Press OK on your Roku remote to begin linking a Twitch account.
138 |
139 |
140 | error_link_canceled
141 | Account linking was canceled.
142 |
143 |
144 | title_link
145 | Link your Twitch Account
146 |
147 |
148 | message_link
149 | On a web browser, go to {1} and enter the following code:
150 |
151 |
152 | message_link_close
153 | This screen will automatically close once the link has completed successfully.
154 |
155 |
156 | error_link_failed
157 | Account linking failed.
158 |
159 |
160 | error_token_write_fail
161 | Failed to save authentication data.
162 |
163 |
164 | error_link_timeout
165 | Account linking timed out.
166 |
167 |
168 | message_link_success
169 | A Twitch account has been linked.
170 |
171 |
172 | title_info
173 | Info
174 |
175 |
176 | error_video
177 | An error occurred while playing a video.
178 |
179 |
180 | title_search
181 | Search
182 |
183 |
184 | title_settings
185 | Settings
186 |
187 |
188 | title_info
189 | Info
190 |
191 |
192 | title_oss
193 | Open Source Software
194 |
195 |
196 | title_privacy_policy
197 | Privacy Policy
198 |
199 |
200 | title_log_in_out
201 |
202 |
203 |
204 | title_log_in
205 | Log In
206 |
207 |
208 | title_log_out
209 | Log Out
210 |
211 |
212 | message_log_out
213 | Log out successful.
214 |
215 |
216 | message_settings_info
217 | Information about the Twitched app is available at {1}.
218 |
219 |
220 | message_settings_oss
221 | Information about open source software used in Twitched is available at {1}.
222 |
223 |
224 | message_settings_privacy_policy
225 | Twitched's privacy policy is available at {1}.
226 |
227 |
228 | button_search
229 | Search
230 |
231 |
232 | button_search_channels
233 | Channels
234 |
235 |
236 | button_search_games
237 | Games
238 |
239 |
240 | message_no_data
241 | No data
242 |
243 |
244 | error_stream_offline
245 | The stream is offline.
246 |
247 |
248 | title_joined
249 | Joined
250 |
251 |
252 | title_views
253 | Total Views
254 |
255 |
256 | inline_playing
257 | playing
258 |
259 |
260 | message_irc_connecting
261 | Connecting to chat
262 |
263 |
264 | message_irc_connected
265 | Connected to chat
266 |
267 |
268 | twitched
269 | Twitched
270 |
271 |
272 | title_chat
273 | Chat
274 |
275 |
276 | title_videos
277 | Videos
278 |
279 |
280 | button_vods
281 | Videos
282 |
283 |
284 | button_all
285 | All
286 |
287 |
288 | button_upload
289 | Upload
290 |
291 |
292 | button_highlight
293 | Highlight
294 |
295 |
296 | button_archive
297 | Archive
298 |
299 |
300 | title_video_type
301 | Video Type
302 |
303 |
304 | title_video_type_all
305 | All
306 |
307 |
308 | title_video_type_upload
309 | Uploads
310 |
311 |
312 | title_video_type_archive
313 | Archive
314 |
315 |
316 | title_video_type_highlight
317 | Highlights
318 |
319 |
320 | title_published_at
321 | Published
322 |
323 |
324 | title_loading
325 | Loading
326 |
327 |
328 | title_description
329 | Description
330 |
331 |
332 | button_follow
333 | Follow
334 |
335 |
336 | button_unfollow
337 | Unfollow
338 |
339 |
340 | inline_on
341 | on
342 |
343 |
344 | inline_playing
345 | playing
346 |
347 |
348 | title_language
349 | Language
350 |
351 |
352 | error_language_write_fail
353 | Failed to save language data.
354 |
355 |
356 | title_quality
357 | Quality
358 |
359 |
360 | title_video_quality
361 | Video Quality
362 |
363 |
364 | title_automatic
365 | Automatic
366 |
367 |
368 | title_status
369 | Status
370 |
371 |
372 | inline_offline
373 | offline
374 |
375 |
376 | title_search_history
377 | Recent Search History
378 |
379 |
380 | title_search_history_clear
381 | Clear Recent Search History
382 |
383 |
384 | title_hls_local
385 | Local Stream Fetching
386 |
387 |
388 | title_enabled
389 | Enabled
390 |
391 |
392 | title_disabled
393 | Disabled
394 |
395 |
396 | message_hls_local
397 | If enabled, stream data will be parsed locally instead of on Twitched's servers.
398 |
399 |
400 | error_hls_local_write_fail
401 | Failed to save stream fetching settings.
402 |
403 |
404 | message_no_description
405 | No description provided.
406 |
407 |
408 | title_unknown
409 | Unknown
410 |
411 |
412 | title_followed_communities
413 | Followed Communities
414 |
415 |
416 | title_followed_games
417 | Followed Games
418 |
419 |
420 | title_follow
421 | Follow
422 |
423 |
424 | title_unfollow
425 | Unfollow
426 |
427 |
428 | title_start_menu
429 | Start Menu
430 |
431 |
432 | message_start_menu
433 | Select which menu will be shown on app launch.
434 |
435 |
436 | error_start_menu_index_write_fail
437 | Failed to save start menu.
438 |
439 |
440 | error_bookmark_write_fail
441 | Failed to save bookmark.
442 |
443 |
444 | title_stream_drm
445 | DRM Stream
446 |
447 |
448 | error_stream_drm
449 | Cannot play DRM stream.
450 |
451 |
452 | message_tcl_detected
453 | TCL Roku Detected
454 |
455 |
456 | message_tcl_loading
457 | Due to unsupported codecs by TCL Roku devices a loading screen may appear for up to one minute while a Twitch embedded advertisement plays.
458 |
459 |
460 | title_chat_delay
461 | Chat Delay
462 |
463 |
464 | error_chat_delay_write_fail
465 | Failed to save chat delay preferences.
466 |
467 |
468 |
469 |
470 |
--------------------------------------------------------------------------------
/locale/es_ES/translations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 | title_featured
9 | Destacado
10 |
11 |
12 | title_games
13 | Juegos
14 |
15 |
16 | title_creative
17 | Creativo
18 |
19 |
20 | title_communities
21 | Comunidades
22 |
23 |
24 | title_popular
25 | Popular
26 |
27 |
28 | title_followed
29 | Seguido
30 |
31 |
32 | error_api_fail
33 | Error al conectarse al servidor API.
34 |
35 |
36 | title_community
37 | Comunidad
38 |
39 |
40 | button_play
41 | Jugar
42 |
43 |
44 | title_stream
45 | Transmisión
46 |
47 |
48 | prefix_streamer
49 | Emisor
50 |
51 |
52 | prefix_game
53 | Juego
54 |
55 |
56 | title_error
57 | Error
58 |
59 |
60 | title_error_code
61 | Código de Error
62 |
63 |
64 | title_viewers
65 | Espectadores
66 |
67 |
68 | title_language
69 | Idioma
70 |
71 |
72 | title_uptime
73 | Tiempo de Actividad
74 |
75 |
76 | title_stream_type
77 | Tipo
78 |
79 |
80 | inline_viewers_singular
81 | espectador
82 |
83 |
84 | inline_viewers_plural
85 | espectadores
86 |
87 |
88 | inline_hours_singular
89 | hora
90 |
91 |
92 | inline_hours_plural
93 | horas
94 |
95 |
96 | inline_minutes_singular
97 | minute
98 |
99 |
100 | inline_minutes_plural
101 | minuto
102 |
103 |
104 | title_exit_confirm
105 | Salida
106 |
107 |
108 | message_exit_confirm
109 | ¿Te gustaría salir de Twitched?
110 |
111 |
112 | button_cancel
113 | Cancelar
114 |
115 |
116 | button_confirm
117 | Confirmar
118 |
119 |
120 | title_loading_stream
121 | Cargando Transmisión
122 |
123 |
124 | error_stream_not_found
125 | La transmisión no es en vivo o no existe.
126 |
127 |
128 | error_deep_link_invalid
129 | No se pudo iniciar el contenido.
130 |
131 |
132 | message_loading
133 | Cargando
134 |
135 |
136 | message_web_link
137 | Presiona OK en tu control remoto Roku para comenzar a vincular una cuenta Twitch.
138 |
139 |
140 | error_link_canceled
141 | El enlace de la cuenta fue cancelado.
142 |
143 |
144 | title_link
145 | Enlace su Cuenta Twitch
146 |
147 |
148 | message_link
149 | En un navegador web, vaya a {1} e ingrese el siguiente código:
150 |
151 |
152 | message_link_close
153 | Esta pantalla se cerrará automáticamente una vez que el enlace se haya completado con éxito.
154 |
155 |
156 | error_link_failed
157 | Falló el enlace de la cuenta.
158 |
159 |
160 | error_token_write_fail
161 | Error al guardar los datos de autenticación.
162 |
163 |
164 | error_link_timeout
165 | Se agotó el tiempo de espera de la cuenta.
166 |
167 |
168 | message_link_success
169 | Se ha vinculado una cuenta de Twitch.
170 |
171 |
172 | title_info
173 | Información
174 |
175 |
176 | error_video
177 | Se produjo un error al reproducir un video.
178 |
179 |
180 | title_search
181 | Buscar
182 |
183 |
184 | title_settings
185 | Configuraciones
186 |
187 |
188 | title_info
189 | Información
190 |
191 |
192 | title_oss
193 | Software de Código Abierto
194 |
195 |
196 | title_privacy_policy
197 | Política de Privacidad
198 |
199 |
200 | title_log_in_out
201 |
202 |
203 |
204 | title_log_in
205 | Iniciar Sesión
206 |
207 |
208 | title_log_out
209 | Cerrar Sesión
210 |
211 |
212 | message_log_out
213 | Cierre la sesión con éxito.
214 |
215 |
216 | message_settings_info
217 | La información sobre la aplicación Twitched está disponible en {1}.
218 |
219 |
220 | message_settings_oss
221 | La información sobre el software de código abierto utilizado en Twitched está disponible en {1}.
222 |
223 |
224 | message_settings_privacy_policy
225 | La política de privacidad de Twitched está disponible en {1}.
226 |
227 |
228 | button_search
229 | Buscar
230 |
231 |
232 | button_search_channels
233 | Canales
234 |
235 |
236 | button_search_games
237 | Juegos
238 |
239 |
240 | message_no_data
241 | Sin datos
242 |
243 |
244 | error_stream_offline
245 | La transmisión está fuera de línea.
246 |
247 |
248 | title_joined
249 | Cuenta Creada
250 |
251 |
252 | title_views
253 | Vistas Totales
254 |
255 |
256 | inline_playing
257 | jugando
258 |
259 |
260 | message_irc_connecting
261 | Conectando al chat
262 |
263 |
264 | message_irc_connected
265 | Conectado al chat
266 |
267 |
268 | twitched
269 | Twitched
270 |
271 |
272 | title_chat
273 | Chat
274 |
275 |
276 | title_videos
277 | Videos
278 |
279 |
280 | button_vods
281 | Videos
282 |
283 |
284 | button_all
285 | Todas
286 |
287 |
288 | button_upload
289 | Subir
290 |
291 |
292 | button_highlight
293 | Realce
294 |
295 |
296 | button_archive
297 | Archivo
298 |
299 |
300 | title_video_type
301 | Tipo de Video
302 |
303 |
304 | title_video_type_all
305 | Todas
306 |
307 |
308 | title_video_type_upload
309 | Subidas
310 |
311 |
312 | title_video_type_archive
313 | Archivo
314 |
315 |
316 | title_video_type_highlight
317 | Reflejos
318 |
319 |
320 | title_published_at
321 | Publicado
322 |
323 |
324 | title_loading
325 | Cargando
326 |
327 |
328 | title_description
329 | Descripción
330 |
331 |
332 | button_follow
333 | Seguir
334 |
335 |
336 | button_unfollow
337 | Dejar de Seguir
338 |
339 |
340 | inline_on
341 | en
342 |
343 |
344 | inline_playing
345 | jugando
346 |
347 |
348 | title_language
349 | Idioma
350 |
351 |
352 | error_language_write_fail
353 | Error al guardar los datos de idioma.
354 |
355 |
356 | title_quality
357 | Calidad
358 |
359 |
360 | title_video_quality
361 | Calidad de video
362 |
363 |
364 | title_automatic
365 | Automático
366 |
367 |
368 | title_status
369 | Estado
370 |
371 |
372 | inline_offline
373 | desconectado
374 |
375 |
376 | title_search_history
377 | Historial de Búsqueda Reciente
378 |
379 |
380 | title_search_history_clear
381 | Borrar Historial de Búsqueda Reciente
382 |
383 |
384 | title_hls_local
385 | Procesamiento de Transmisión Local
386 |
387 |
388 | title_enabled
389 | Habilitado
390 |
391 |
392 | title_disabled
393 | Deshabilitado
394 |
395 |
396 | message_hls_local
397 | Si está habilitado, los datos de transmisión se analizarán localmente en lugar de en los servidores de Twitched.
398 |
399 |
400 | error_hls_local_write_fail
401 | Error al guardar la configuración de búsqueda de la transmisión.
402 |
403 |
404 | message_no_description
405 | Ninguna descripción provista.
406 |
407 |
408 | title_unknown
409 | Desconocido
410 |
411 |
412 | title_followed_communities
413 | Comunidades Seguidas
414 |
415 |
416 | title_followed_games
417 | Juegos Seguidos
418 |
419 |
420 | title_follow
421 | Seguir
422 |
423 |
424 | title_unfollow
425 | Dejar de Seguir
426 |
427 |
428 | title_start_menu
429 | Menu de Inicio
430 |
431 |
432 | message_start_menu
433 | Seleccione qué menú se mostrará en el inicio de la aplicación.
434 |
435 |
436 | error_start_menu_index_write_fail
437 | Error al guardar el menú de inicio.
438 |
439 |
440 | error_bookmark_write_fail
441 | Error al guardar el marcador.
442 |
443 |
444 | title_stream_drm
445 | Transmisión DRM
446 |
447 |
448 | error_stream_drm
449 | No se puede reproducir la transmisión DRM.
450 |
451 |
452 | message_tcl_detected
453 | TCL Roku Detectado
454 |
455 |
456 | message_tcl_loading
457 | Debido a los codecs no admitidos por los dispositivos TCL Roku, puede aparecer una pantalla de carga durante hasta un minuto mientras se reproduce un anuncio integrado de Twitch.
458 |
459 |
460 | title_chat_delay
461 | Retraso de chat
462 |
463 |
464 | error_chat_delay_write_fail
465 | Error al guardar las preferencias de demora de chat.
466 |
467 |
468 |
469 |
470 |
--------------------------------------------------------------------------------
/components/InfoScreen.brs:
--------------------------------------------------------------------------------
1 | ' Copyright (C) 2017 Rolando Islas. All Rights Reserved.
2 |
3 | ' Create a new instance of the InfoScreen component
4 | function init() as void
5 | ' Constants
6 | m.BUTTON_PLAY = 0
7 | m.BUTTON_GAME = 1
8 | m.BUTTON_STREAMER = 2
9 | m.BUTTON_VODS = 3
10 | m.TYPE_ALL = 0
11 | m.TYPE_UPLOAD = 1
12 | m.TYPE_HIGHLIGHT = 2
13 | m.TYPE_ARCHIVE = 3
14 | m.BUTTON_FOLLOW_UNFOLLOW = 0
15 | m.BUTTON_ERROR_CONFIRM = 0
16 | ' Components
17 | m.preview = m.top.findNode("preview")
18 | m.title = m.top.findNode("title")
19 | m.buttons = m.top.findNode("buttons")
20 | m.twitch_api = m.top.findNode("twitch_api")
21 | m.vods = m.top.findNode("vods_list")
22 | m.message = m.top.findNode("message")
23 | m.dialog = m.top.findNode("dialog")
24 | m.loading_dialog = m.top.findNode("loading_dialog")
25 | ' Info Group
26 | m.info_group = m.top.findNode("stream_info")
27 | m.viewers = m.info_group.findNode("viewers")
28 | m.start_time = m.info_group.findNode("start_time")
29 | m.language = m.info_group.findNode("language")
30 | m.stream_type = m.info_group.findNode("stream_type")
31 | ' Init
32 | m.video_selected = invalid
33 | m.dialog_type = "video_type"
34 | m.user_info = invalid
35 | m.is_following = false
36 | init_logging()
37 | m.video_type = tr("title_videos")
38 | m.buttons.buttons = ["", "", "", ""]
39 | m.button_callbacks = []
40 | set_button(m.BUTTON_PLAY, tr("button_play"), "on_play_button_pressed")
41 | set_button(m.BUTTON_VODS, tr("button_vods"), "on_vods_button_pressed")
42 | ' Events
43 | m.top.observeField("preview_image", "on_set_field")
44 | m.top.observeField("title", "on_set_field")
45 | m.top.observeField("streamer", "on_set_field")
46 | m.top.observeField("game", "on_set_field")
47 | m.top.observeField("visible", "on_set_field")
48 | m.top.observeField("focus", "on_set_field")
49 | m.top.observeField("viewers", "on_set_field")
50 | m.top.observeField("start_time", "on_set_field")
51 | m.top.observeField("language", "on_set_field")
52 | m.top.observeField("stream_type", "on_set_field")
53 | m.top.observeField("token", "on_set_field")
54 | m.top.observeField("video_selected", "on_set_field")
55 | m.buttons.observeField("buttonSelected", "on_button_selected")
56 | m.twitch_api.observeField("result", "on_callback")
57 | m.vods.observeField("rowItemSelected", "on_video_selected")
58 | m.dialog.observeField("buttonSelected", "on_dialog_button_selected")
59 | m.dialog.observeField("wasClosed", "on_dialog_closed")
60 | end function
61 |
62 | ' Handle callback
63 | function on_callback(event as object) as void
64 | callback = event.getData().callback
65 | if callback = "on_user_info"
66 | on_user_info(event)
67 | else if callback = "on_follow_info"
68 | on_follow_info(event)
69 | else if callback = "on_video_data"
70 | on_video_data(event)
71 | else if callback = "on_follow_channel"
72 | on_follow_channel(event)
73 | else if callback = "on_unfollow_channel"
74 | on_unfollow_channel(event)
75 | else if callback = "on_stream_data"
76 | on_stream_data(event)
77 | else if callback = "on_hls_data"
78 | on_hls_data(event)
79 | else
80 | if callback = invalid
81 | callback = ""
82 | end if
83 | printl(m.WARN, "on_callback: Unhandled callback: " + callback)
84 | end if
85 | end function
86 |
87 | ' Handle keys
88 | function onKeyEvent(key as string, press as boolean) as boolean
89 | printl(m.DEBUG, "InfoScreen - Key: " + key + " Press: " + press.toStr())
90 | ' VODs
91 | if m.vods.hasFocus()
92 | ' Return to info screen original state
93 | if press and (key = "up" or key = "back")
94 | reset(m.BUTTON_VODS)
95 | return true
96 | ' Show video type selection dialog
97 | else if press and key = "options"
98 | m.dialog.optionsDialog = true
99 | m.dialog.title = tr("title_video_type")
100 | m.dialog.message = ""
101 | m.dialog.buttons = [
102 | tr("button_all"),
103 | tr("button_upload"),
104 | tr("button_highlight"),
105 | tr("button_archive")
106 | ]
107 | m.dialog_type = "video_type"
108 | m.dialog.focusButton = 0
109 | m.dialog.visible = true
110 | m.top.setField("dialog", m.dialog)
111 | end if
112 | end if
113 | return false
114 | end function
115 |
116 | ' Check for visibility and focus the buttons
117 | function on_set_visible(event as object) as void
118 | ' Not visible
119 | if event.getField() = "visible" and not event.getData()
120 | m.twitch_api.cancel = true
121 | ' Visible event
122 | else if event.getField() = "visible" and event.getData()
123 | reset()
124 | ' Focus event
125 | else if event.getField() = "focus"
126 | ' Reset
127 | if event.getData() = "reset"
128 | reset()
129 | ' Focus vods if visible
130 | else if event.getData() = "true"
131 | button = m.BUTTON_PLAY
132 | if m.vods.visible
133 | button = m.BUTTON_VODS
134 | end if
135 | reset(button, m.vods.visible)
136 | end if
137 | end if
138 | end function
139 |
140 | ' Reset the info screen state
141 | function reset(button = 0 as integer, focus_vods = false as boolean) as void
142 | m.dialog.visible = false
143 | m.dialog.close = true
144 | m.loading_dialog.visible = false
145 | m.loading_dialog.close = true
146 | m.buttons.focusButton = button
147 | m.buttons.setFocus(not focus_vods)
148 | m.vods.setFocus(focus_vods)
149 | m.vods.visible = focus_vods
150 | m.info_group.visible = not focus_vods
151 | m.message.text = ""
152 | m.top.video_selected = invalid
153 | if (not focus_vods) and (not is_video())
154 | m.video_selected = invalid
155 | end if
156 | m.top.setField("options", false)
157 | m.video_type = tr("title_videos")
158 | m.dialog_type = ""
159 | end function
160 |
161 | ' Check if the info screen is displaying info about a video
162 | function is_video() as boolean
163 | stream_type = m.top.getField("stream_type")
164 | return stream_type = "upload" or stream_type = "archive" or stream_type = "highlight"
165 | end function
166 |
167 | ' Check if the info screen is displaying a user
168 | function is_user() as boolean
169 | stream_type = m.top.getField("stream_type")
170 | return stream_type = "user" or stream_type = "user_follow"
171 | end function
172 |
173 | ' Check if the type is a DRM stream
174 | function is_drm() as boolean
175 | stream_type = m.top.getField("stream_type")
176 | if stream_type = invalid
177 | return false
178 | end if
179 | return ucase(stream_type).instr(0, "DRM") > -1
180 | end function
181 |
182 | ' Handle a button selection
183 | function on_button_selected(event as object) as void
184 | callback = m.button_callbacks[event.getData()]
185 | if callback = "on_play_button_pressed"
186 | on_play_button_pressed()
187 | else if callback = "on_vods_button_pressed"
188 | on_vods_button_pressed()
189 | else if callback = "on_streamer_button_pressed"
190 | on_streamer_button_pressed()
191 | else if callback = "on_game_button_pressed"
192 | on_game_button_pressed()
193 | end if
194 | end function
195 |
196 | ' Handle field updates
197 | function on_set_field(event as object) as void
198 | field = event.getField()
199 | ' Image
200 | if field = "preview_image"
201 | m.preview.uri = event.getData()
202 | ' Title
203 | else if field = "title"
204 | m.title.text = event.getData()
205 | ' Streamer
206 | else if field = "streamer"
207 | set_button(m.BUTTON_STREAMER, tr("prefix_streamer") + ": " + event.getData()[0], "on_streamer_button_pressed")
208 | ' Game
209 | else if field = "game"
210 | set_button(m.BUTTON_GAME, tr("prefix_game") + ": " + event.getData()[0], "on_game_button_pressed")
211 | ' Visible
212 | else if field = "visible" or field = "focus"
213 | on_set_visible(event)
214 | ' Viewers
215 | else if field = "viewers"
216 | if is_user() or is_video()
217 | m.viewers.text = tr("title_views") + ": " + pretty_number(event.getData())
218 | else
219 | m.viewers.text = tr("title_viewers") + ": " + pretty_number(event.getData())
220 | end if
221 | ' Start time
222 | else if field = "start_time"
223 | set_time(event.getData())
224 | ' Language
225 | else if field = "language"
226 | m.language.text = tr("title_language") + ": " + event.getData()
227 | ' Stream type
228 | else if field = "stream_type"
229 | m.stream_type.text = tr("title_stream_type") + ": " + event.getData()
230 | m.top.setField("start_time", m.top.getField("start_time"))
231 | m.top.setField("viewers", m.top.getField("viewers"))
232 | ' Token
233 | else if field = "token"
234 | m.twitch_api.user_token = event.getData()
235 | ' Video selected
236 | else if field = "video_selected"
237 | if event.getData() <> invalid and m.video_selected = invalid
238 | m.video_selected = event.getData()
239 | end if
240 | end if
241 | end function
242 |
243 | ' Set the time the stream started
244 | ' @param time_string time string in ISO8601 format
245 | function set_time(time_string as string) as void
246 | ' Time from string
247 | time = createObject("roDateTime")
248 | time.fromISO8601String(time_string)
249 | ' Check if the time parsed correctly
250 | if time.getYear() <= 1970
251 | m.start_time.text = tr("title_status") + ": " + tr("inline_offline")
252 | return
253 | ' This is a user follow
254 | else if is_user_follow()
255 | time.toLocalTime()
256 | m.start_time.text = tr("title_followed") + ": " + time.asDateString("short-month-no-weekday")
257 | return
258 | ' Show time created if the type is a user
259 | else if is_user()
260 | time.toLocalTime()
261 | m.start_time.text = tr("title_joined") + ": " + time.asDateString("short-month-no-weekday")
262 | return
263 | ' Show published at if the type is a VOD
264 | else if is_video()
265 | time.toLocalTime()
266 | m.start_time.text = tr("title_published_at") + ": " + time.asDateString("short-month-no-weekday")
267 | return
268 | end if
269 | ' Show the up time of a stream
270 | now = createObject("roDateTime")
271 | ' Calculate up time
272 | total_seconds = now.asSeconds() - time.asSeconds()
273 | hours = int(total_seconds / (60 * 60))
274 | minutes = int((total_seconds / (60 * 60) - hours) * 60)
275 | ' Set up time
276 | m.start_time.text = tr("title_uptime") + ": "
277 | if hours > 0
278 | m.start_time.text += hours.toStr() + " " + trs("inline_hours", hours) + " "
279 | end if
280 | m.start_time.text += minutes.toStr() + " " + trs("inline_minutes", minutes)
281 | end function
282 |
283 | ' Check if the type is a user_follow. User is_user() for a general
284 | ' user/user_follow check
285 | function is_user_follow()
286 | stream_type = m.top.getField("stream_type")
287 | return stream_type = "user_follow"
288 | end function
289 |
290 | ' Handle the streamer button being pressed
291 | function on_streamer_button_pressed() as void
292 | ' Show loading dialog
293 | m.dialog.optionsDialog = false
294 | m.dialog.title = tr("title_loading")
295 | m.dialog.message = tr("title_loading")
296 | m.dialog.buttons = []
297 | m.dialog.visible = true
298 | m.dialog_type = "loading"
299 | m.top.setField("dialog", m.dialog)
300 | ' Load streamer info
301 | streamer = m.top.getField("streamer")[1]
302 | m.twitch_api.cancel = true
303 | m.twitch_api.get_user_info = [{
304 | login: streamer
305 | }, "on_user_info"]
306 | end function
307 |
308 | ' Handle user info
309 | function on_user_info(event as object) as void
310 | ' Validate
311 | users = event.getData().result
312 | if type(users) <> "roArray" or users.count() < 1 or type(users[0]) <> "roAssociativeArray"
313 | error(3000)
314 | return
315 | end if
316 | ' Save user info
317 | m.user_info = users[0]
318 | ' Request follow info
319 | m.twitch_api.cancel = true
320 | m.twitch_api.get_follows = [{
321 | limit: 1,
322 | from_login: m.top.user_name,
323 | to_login: m.top.streamer[1],
324 | no_cache: "true"
325 | }, "on_follow_info"]
326 | end function
327 |
328 | function on_follow_info(event as object) as void
329 | printl(m.DEBUG, "InfoScreen: on_follow_info")
330 | ' Validate
331 | follows = event.getData().result
332 | if type(follows) <> "roArray"
333 | print follows
334 | error(3001)
335 | return
336 | end if
337 | ' Save follow info
338 | m.is_following = follows.count() > 0
339 | ' Show user info dialog
340 | m.dialog.optionsDialog = false
341 | m.dialog.title = m.top.streamer[0]
342 | m.dialog.message = clean(m.user_info.description)
343 | if m.top.user_name = "" or m.top.token = ""
344 | m.dialog.buttons = []
345 | else if m.is_following
346 | m.dialog.buttons = [tr("button_unfollow")]
347 | else
348 | m.dialog.buttons = [tr("button_follow")]
349 | end if
350 | m.dialog.focusButton = 0
351 | m.dialog_type = "user_info"
352 | m.dialog.visible = true
353 | m.top.setField("dialog", m.dialog)
354 | end function
355 |
356 | ' Show the dialog error
357 | function error(code as integer) as void
358 | m.optionsDialog = false
359 | m.dialog.title = tr("title_error")
360 | m.dialog.message = tr("error_api_fail") + chr(10) + tr("title_error_code") + ": " + code.toStr()
361 | m.dialog.buttons = [tr("button_confirm")]
362 | m.dialog.focusButton = 0
363 | m.dialog_type = "error"
364 | m.dialog.visible = true
365 | m.top.setField("dialog", m.dialog)
366 | end function
367 |
368 | ' Handle the game button being pressed
369 | function on_game_button_pressed() as void
370 | m.top.setField("game_selected", true)
371 | end function
372 |
373 | ' Handle play button
374 | function on_play_button_pressed() as void
375 | printl(m.DEBUG, "InfoScreen: Play button pressed")
376 | video_type = "stream"
377 | video_id = invalid
378 | ' Stream play
379 | if not is_video()
380 | if is_drm()
381 | show_drm_message()
382 | return
383 | else if m.global.twitched_config.force_remote_hls
384 | m.twitch_api.get_streams = [{
385 | user_id: m.top.streamer[2]
386 | }, "on_stream_data"]
387 | else
388 | m.twitch_api.get_hls_url = [m.twitch_api.HLS_TYPE_STREAM, m.top.streamer[1],
389 | m.global.P720, "on_hls_data", true]
390 | end if
391 | ' Video play
392 | else
393 | if m.video_selected <> invalid
394 | m.top.setField("video_selected", m.video_selected)
395 | video_type = "VOD"
396 | video_id = m.video_selected.id
397 | else
398 | m.top.setField("play_selected", true)
399 | end if
400 | end if
401 | show_loading_dialog()
402 | track_video_play("Play Button", video_type, m.top.streamer[1], m.top.streamer[2], video_id)
403 | end function
404 |
405 | ' Handle stream data and either attempt to play video or show offline error
406 | function on_stream_data(event as object) as void
407 | streams = event.getData().result
408 | if type(streams) <> "roArray"
409 | m.loading_dialog.visible = false
410 | m.buttons.setFocus(true)
411 | error(3002)
412 | else
413 | ' Offline
414 | if streams.count() <> 1
415 | m.loading_dialog.visible = false
416 | m.buttons.setFocus(true)
417 | show_offline_message(3003)
418 | ' Online
419 | else
420 | m.top.setField("play_selected", true)
421 | end if
422 | end if
423 | end function
424 |
425 | ' Handle stream data (HLS) and either attempt to play video or show offline error
426 | function on_hls_data(event as object) as void
427 | ' HLS data was returned. The stream is online
428 | if event <> invalid and type(event.getData().result) = "roAssociativeArray"
429 | m.top.setField("play_selected", true)
430 | ' There was an error or the stream is offline
431 | else
432 | m.loading_dialog.visible = false
433 | m.buttons.setFocus(true)
434 | show_offline_message(3004)
435 | end if
436 | end function
437 |
438 | ' Show a stream offline message dialog
439 | function show_offline_message(error_code as integer) as void
440 | m.optionsDialog = false
441 | m.dialog.title = tr("title_error")
442 | m.dialog.message = tr("error_stream_offline") + chr(10) + tr("title_error_code") + ": " + error_code.toStr()
443 | m.dialog.buttons = [tr("button_confirm")]
444 | m.dialog.focusButton = 0
445 | m.dialog_type = "error"
446 | m.dialog.visible = true
447 | m.top.setField("dialog", m.dialog)
448 | end function
449 |
450 | ' Show drm message
451 | function show_drm_message() as void
452 | m.optionsDialog = false
453 | m.dialog.title = tr("title_stream_drm")
454 | m.dialog.message = tr("error_stream_drm")
455 | m.dialog.buttons = [tr("button_confirm")]
456 | m.dialog.focusButton = 0
457 | m.dialog_type = "error"
458 | m.dialog.visible = true
459 | m.top.setField("dialog", m.dialog)
460 | end function
461 |
462 | ' Send analytics data for a video play
463 | ' All parameters are expected to be a string or invalid
464 | function track_video_play(event_action as string, video_type as string, streamer_name as object, streamer_id as object, video_id = invalid as object) as void
465 | deep_link_params = ""
466 | if streamer_name <> invalid
467 | deep_link_params += "Streamer name: " + streamer_name + " "
468 | end if
469 | if streamer_id <> invalid
470 | deep_link_params += "Streamer ID: " + streamer_id + " "
471 | end if
472 | if video_id <> invalid
473 | deep_link_params += "Video ID: " + video_id + " "
474 | end if
475 | deep_link_params += "Type: " + video_type
476 | m.global.analytics.trackEvent = {
477 | google: {
478 | ec: "Video",
479 | ea: event_action,
480 | el: deep_link_params,
481 | }
482 | }
483 | end function
484 |
485 | ' Display the loading dialog
486 | ' This dialog is not dismissible
487 | function show_loading_dialog()
488 | m.optionsDialog = false
489 | m.loading_dialog.title = tr("title_loading")
490 | m.loading_dialog.visible = true
491 | ' This is not set as the dialog on the main scene, otherwise the user would
492 | ' be able to dismiss it via the back button. This is, however, focused so
493 | ' the key handler will not be able to perform any actions while it is shown
494 | m.loading_dialog.setFocus(true)
495 | end function
496 |
497 | ' Handle vods button press
498 | function on_vods_button_pressed(video_type = 0 as integer) as void
499 | ' Clear VODs
500 | m.vods.content.removeChildrenIndex(m.vods.content.getChildCount(), 0)
501 | ' Show loading message
502 | m.info_group.visible = false
503 | m.message.text = tr("message_loading")
504 | ' Determine type
505 | video_type_string = ""
506 | if video_type = m.TYPE_ALL
507 | video_type_string = "all"
508 | else if video_type = m.TYPE_UPLOAD
509 | video_type_string = "upload"
510 | else if video_type = m.TYPE_ARCHIVE
511 | video_type_string = "archive"
512 | else if video_type = m.TYPE_HIGHLIGHT
513 | video_type_string = "highlight"
514 | end if
515 | m.video_type = tr("title_video_type_" + video_type_string)
516 | ' Request videos
517 | m.twitch_api.cancel = true
518 | m.twitch_api.get_videos = [{
519 | limit: 50,
520 | user_id: m.top.getField("streamer")[2],
521 | type: video_type_string
522 | }, "on_video_data"]
523 | m.top.setField("options", true)
524 | end function
525 |
526 | ' Set a buttons contents and callback
527 | function set_button(index as integer, name as string, callback as string) as void
528 | ' The buttons field is overwritten to trigger the observe field event
529 | ' Updating an entry will not update the menu
530 | buttons = m.buttons.buttons
531 | buttons[index] = name
532 | m.buttons.buttons = buttons
533 | ' Set callback
534 | m.button_callbacks[index] = callback
535 | end function
536 |
537 | ' Handle video data
538 | function on_video_data(event as object) as void
539 | m.vods.content.removeChildrenIndex(m.vods.content.getChildCount(), 0)
540 | ' Parse event data
541 | videos = event.getData().result
542 | if type(videos) <> "roArray"
543 | printl(m.DEBUG, "InfoScreen: video data invalid")
544 | m.message.text = tr("error_api_fail")
545 | return
546 | end if
547 | m.message.text = ""
548 | ' Add row of video items
549 | row = m.vods.content.createChild("ContentNode")
550 | row.title = m.video_type
551 | populated = false
552 | for each video_data in videos
553 | if type(video_data) = "roAssociativeArray"
554 | if video_data.duration_seconds > 0
555 | video = row.createChild("VodItemData")
556 | video.image_url = video_data.thumbnail_url.replace("{width}", "438").replace("{height}", "270")
557 | video.title = clean(video_data.title)
558 | video.id = video_data.id
559 | video.duration = video_data.duration_seconds
560 | populated = true
561 | else
562 | printl(m.DEBUG, "InfoScreen: Video duration is 0 seconds. It has likely been deleted.")
563 | end if
564 | else
565 | printl(m.DEBUG, "InfoScreen: video item data invalid")
566 | end if
567 | end for
568 | if not populated
569 | m.vods.content.removeChildrenIndex(m.vods.content.getChildCount(), 0)
570 | m.message.text = tr("message_no_data")
571 | end if
572 | ' Show vods
573 | m.vods.visible = true
574 | m.vods.setFocus(true)
575 | end function
576 |
577 | ' Handle a video being selected
578 | function on_video_selected(event as object) as void
579 | selection = event.getData()
580 | ' Not first now
581 | if selection[0] <> 0
582 | return
583 | end if
584 | row = m.vods.content.getChild(selection[0])
585 | if row = invalid
586 | return
587 | end if
588 | video = row.getChild(selection[1])
589 | if video = invalid
590 | return
591 | end if
592 | m.top.setField("video_selected", video)
593 | show_loading_dialog()
594 | track_video_play("Video Selected", "VOD", m.top.streamer[1], m.top.streamer[2], video.id)
595 | end function
596 |
597 | ' Handle dialog button
598 | function on_dialog_button_selected(event as object) as void
599 | m.dialog.close = true
600 | button = event.getData()
601 | printl(m.DEBUG, "InfoScreen dialog button: " + button.toStr())
602 | if m.dialog_type = "video_type"
603 | on_vods_button_pressed(button)
604 | else if m.dialog_type = "user_info"
605 | on_user_info_button_pressed(button)
606 | else if m.dialog_type = "error"
607 | on_error_dialog_button_pressed(button)
608 | else if m.dialog_type = "loading"
609 | ' Ignore
610 | end if
611 | end function
612 |
613 | function on_error_dialog_button_pressed(button as integer) as void
614 | if button = m.BUTTON_ERROR_CONFIRM
615 | m.dialog.close = true
616 | end if
617 | end function
618 |
619 | ' Handle a button press on the streamer info dialog
620 | function on_user_info_button_pressed(button as integer) as void
621 | if button = m.BUTTON_FOLLOW_UNFOLLOW
622 | if type(m.user_info) <> "roAssociativeArray"
623 | printl(m.DEBUG, "Failed to handle follow/unfollow request. No user info.")
624 | return
625 | end if
626 | if not m.is_following
627 | m.dialog.buttons = [tr("button_follow")]
628 | m.twitch_api.cancel = true
629 | m.twitch_api.follow_channel = [{
630 | id: m.user_info.id
631 | }, "on_follow_channel"]
632 | else
633 | m.dialog.buttons = [tr("button_unfollow")]
634 | m.twitch_api.cancel = true
635 | m.twitch_api.unfollow_channel = [{
636 | id: m.user_info.id
637 | }, "on_unfollow_channel"]
638 | end if
639 | end if
640 | end function
641 |
642 | ' Handle a channel follow
643 | function on_follow_channel(event as object) as void
644 | ' Ignore
645 | printl(m.DEBUG, "Followed channel")
646 | end function
647 |
648 | ' Handle a channel unfollow
649 | function on_unfollow_channel(event as object) as void
650 | ' Ignore
651 | printl(m.DEBUG, "Unfollowed channel")
652 | end function
653 |
654 | ' Handle dialog closing
655 | function on_dialog_closed(event as object) as void
656 | printl(m.DEBUG, "InfoScreen: dialog closed")
657 | if m.dialog_type = "video_type" and not m.vods.hasFocus()
658 | m.vods.setFocus(true)
659 | else if m.dialog_type = "user_info" and not m.buttons.hasFocus()
660 | m.buttons.setFocus(true)
661 | else if m.dialog_type = "error" and not m.buttons.hasFocus()
662 | m.buttons.setFocus(true)
663 | else if m.dialog_type = "loading" and not m.buttons.hasFocus()
664 | m.twitch_api.cancel = true
665 | m.buttons.setFocus(true)
666 | end if
667 | m.dialog_type = ""
668 | end function
669 |
--------------------------------------------------------------------------------