Browse Source

Initial commit

yattoz 4 years ago
commit
77d630896f
100 changed files with 6031 additions and 0 deletions
  1. 14 0
      .gitignore
  2. 1 0
      .idea/.name
  3. 125 0
      .idea/codeStyles/Project.xml
  4. 5 0
      .idea/codeStyles/codeStyleConfig.xml
  5. 9 0
      .idea/dictionaries/dict.xml
  6. 6 0
      .idea/encodings.xml
  7. 19 0
      .idea/gradle.xml
  8. 9 0
      .idea/misc.xml
  9. 12 0
      .idea/runConfigurations.xml
  10. 6 0
      .idea/vcs.xml
  11. 674 0
      LICENSE
  12. 50 0
      README.md
  13. 1 0
      app/.gitignore
  14. 110 0
      app/build.gradle
  15. 21 0
      app/proguard-rules.pro
  16. 24 0
      app/src/androidTest/java/io/r_a_d/radio2/ExampleInstrumentedTest.kt
  17. 75 0
      app/src/main/AndroidManifest.xml
  18. 23 0
      app/src/main/assets/chat.html
  19. BIN
      app/src/main/assets/the_stream_is_down.mp3
  20. BIN
      app/src/main/ic_launcher-web.png
  21. 27 0
      app/src/main/java/io/r_a_d/radio2/Actions.kt
  22. 69 0
      app/src/main/java/io/r_a_d/radio2/Async.kt
  23. 85 0
      app/src/main/java/io/r_a_d/radio2/BaseActivity.kt
  24. 94 0
      app/src/main/java/io/r_a_d/radio2/BaseNotification.kt
  25. 43 0
      app/src/main/java/io/r_a_d/radio2/BootBroadcastReceiver.kt
  26. 284 0
      app/src/main/java/io/r_a_d/radio2/MainActivity.kt
  27. 245 0
      app/src/main/java/io/r_a_d/radio2/NavigationExtensions.kt
  28. 109 0
      app/src/main/java/io/r_a_d/radio2/NowPlayingNotification.kt
  29. 42 0
      app/src/main/java/io/r_a_d/radio2/ParametersActivity.kt
  30. 664 0
      app/src/main/java/io/r_a_d/radio2/RadioService.kt
  31. 21 0
      app/src/main/java/io/r_a_d/radio2/Tickers.kt
  32. 14 0
      app/src/main/java/io/r_a_d/radio2/Values.kt
  33. 117 0
      app/src/main/java/io/r_a_d/radio2/alarm/RadioAlarm.kt
  34. 77 0
      app/src/main/java/io/r_a_d/radio2/alarm/RadioSleeper.kt
  35. 304 0
      app/src/main/java/io/r_a_d/radio2/playerstore/PlayerStore.kt
  36. 58 0
      app/src/main/java/io/r_a_d/radio2/playerstore/Song.kt
  37. 132 0
      app/src/main/java/io/r_a_d/radio2/preferences/AlarmFragment.kt
  38. 80 0
      app/src/main/java/io/r_a_d/radio2/preferences/CustomizeFragment.kt
  39. 27 0
      app/src/main/java/io/r_a_d/radio2/preferences/MainPreferenceFragment.kt
  40. 44 0
      app/src/main/java/io/r_a_d/radio2/preferences/SleepFragment.kt
  41. 63 0
      app/src/main/java/io/r_a_d/radio2/preferences/StreamerNotifServiceFragment.kt
  42. 41 0
      app/src/main/java/io/r_a_d/radio2/streamerNotificationService/ServiceNotification.kt
  43. 114 0
      app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorExtensions.kt
  44. 123 0
      app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorService.kt
  45. 35 0
      app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerNotification.kt
  46. 46 0
      app/src/main/java/io/r_a_d/radio2/streamerNotificationService/WorkerStore.kt
  47. 52 0
      app/src/main/java/io/r_a_d/radio2/ui/chat/ChatFragment.kt
  48. 12 0
      app/src/main/java/io/r_a_d/radio2/ui/chat/ChatViewModel.kt
  49. 47 0
      app/src/main/java/io/r_a_d/radio2/ui/chat/WebViewChat.kt
  50. 16 0
      app/src/main/java/io/r_a_d/radio2/ui/news/News.kt
  51. 67 0
      app/src/main/java/io/r_a_d/radio2/ui/news/NewsAdapter.kt
  52. 62 0
      app/src/main/java/io/r_a_d/radio2/ui/news/NewsFragment.kt
  53. 52 0
      app/src/main/java/io/r_a_d/radio2/ui/news/NewsViewModel.kt
  54. 294 0
      app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingFragment.kt
  55. 34 0
      app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingViewModel.kt
  56. 94 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/SongsFragment.kt
  57. 29 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/SongsPagerAdapter.kt
  58. 86 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/LastPlayedFragment.kt
  59. 70 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/QueueFragment.kt
  60. 66 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/SongAdaptater.kt
  61. 69 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/CooldownCalculator.kt
  62. 132 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/FavoritesFragment.kt
  63. 81 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestFragment.kt
  64. 42 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestResponse.kt
  65. 148 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestSongAdapter.kt
  66. 301 0
      app/src/main/java/io/r_a_d/radio2/ui/songs/request/Requestor.kt
  67. 8 0
      app/src/main/res/color/button_green.xml
  68. 8 0
      app/src/main/res/color/button_green_compat.xml
  69. 8 0
      app/src/main/res/color/button_red.xml
  70. 13 0
      app/src/main/res/drawable-anydpi-v24/ic_stat_new_message.xml
  71. 11 0
      app/src/main/res/drawable-anydpi/ic_alarm.xml
  72. 11 0
      app/src/main/res/drawable-anydpi/ic_av_timer.xml
  73. 11 0
      app/src/main/res/drawable-anydpi/ic_bug.xml
  74. 11 0
      app/src/main/res/drawable-anydpi/ic_chat_processing.xml
  75. 11 0
      app/src/main/res/drawable-anydpi/ic_customize.xml
  76. 10 0
      app/src/main/res/drawable-anydpi/ic_newspaper.xml
  77. 11 0
      app/src/main/res/drawable-anydpi/ic_notification.xml
  78. 11 0
      app/src/main/res/drawable-anydpi/ic_open_in_browser.xml
  79. 11 0
      app/src/main/res/drawable-anydpi/ic_playlist_music.xml
  80. BIN
      app/src/main/res/drawable-hdpi/ic_alarm.png
  81. BIN
      app/src/main/res/drawable-hdpi/ic_av_timer.png
  82. BIN
      app/src/main/res/drawable-hdpi/ic_bug.png
  83. BIN
      app/src/main/res/drawable-hdpi/ic_chat_processing.png
  84. BIN
      app/src/main/res/drawable-hdpi/ic_customize.png
  85. BIN
      app/src/main/res/drawable-hdpi/ic_newspaper.png
  86. BIN
      app/src/main/res/drawable-hdpi/ic_notification.png
  87. BIN
      app/src/main/res/drawable-hdpi/ic_open_in_browser.png
  88. BIN
      app/src/main/res/drawable-hdpi/ic_pause.png
  89. BIN
      app/src/main/res/drawable-hdpi/ic_play.png
  90. BIN
      app/src/main/res/drawable-hdpi/ic_playlist_music.png
  91. BIN
      app/src/main/res/drawable-hdpi/ic_stat_new_message.png
  92. BIN
      app/src/main/res/drawable-hdpi/ic_stat_now_playing.png
  93. BIN
      app/src/main/res/drawable-hdpi/ic_stop.png
  94. BIN
      app/src/main/res/drawable-hdpi/ic_volume_high.png
  95. BIN
      app/src/main/res/drawable-hdpi/ic_volume_low.png
  96. BIN
      app/src/main/res/drawable-hdpi/ic_volume_medium.png
  97. BIN
      app/src/main/res/drawable-hdpi/ic_volume_off.png
  98. BIN
      app/src/main/res/drawable-ldpi/ic_newspaper.png
  99. BIN
      app/src/main/res/drawable-ldpi/ic_pause.png
  100. 0 0
      app/src/main/res/drawable-ldpi/ic_play.png

+ 14 - 0
.gitignore View File

@@ -0,0 +1,14 @@
1
+*.iml
2
+.gradle
3
+/local.properties
4
+/.idea/caches
5
+/.idea/libraries
6
+/.idea/modules.xml
7
+/.idea/workspace.xml
8
+/.idea/navEditor.xml
9
+/.idea/assetWizardSettings.xml
10
+.DS_Store
11
+/build
12
+/captures
13
+.externalNativeBuild
14
+.cxx

+ 1 - 0
.idea/.name View File

@@ -0,0 +1 @@
1
+R-a-dio2

+ 125 - 0
.idea/codeStyles/Project.xml View File

@@ -0,0 +1,125 @@
1
+<component name="ProjectCodeStyleConfiguration">
2
+  <code_scheme name="Project" version="173">
3
+    <AndroidXmlCodeStyleSettings>
4
+      <option name="ARRANGEMENT_SETTINGS_MIGRATED_TO_191" value="true" />
5
+    </AndroidXmlCodeStyleSettings>
6
+    <JetCodeStyleSettings>
7
+      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
8
+    </JetCodeStyleSettings>
9
+    <codeStyleSettings language="XML">
10
+      <indentOptions>
11
+        <option name="CONTINUATION_INDENT_SIZE" value="4" />
12
+      </indentOptions>
13
+      <arrangement>
14
+        <rules>
15
+          <section>
16
+            <rule>
17
+              <match>
18
+                <AND>
19
+                  <NAME>xmlns:android</NAME>
20
+                  <XML_ATTRIBUTE />
21
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
22
+                </AND>
23
+              </match>
24
+            </rule>
25
+          </section>
26
+          <section>
27
+            <rule>
28
+              <match>
29
+                <AND>
30
+                  <NAME>xmlns:.*</NAME>
31
+                  <XML_ATTRIBUTE />
32
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
33
+                </AND>
34
+              </match>
35
+              <order>BY_NAME</order>
36
+            </rule>
37
+          </section>
38
+          <section>
39
+            <rule>
40
+              <match>
41
+                <AND>
42
+                  <NAME>.*:id</NAME>
43
+                  <XML_ATTRIBUTE />
44
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
45
+                </AND>
46
+              </match>
47
+            </rule>
48
+          </section>
49
+          <section>
50
+            <rule>
51
+              <match>
52
+                <AND>
53
+                  <NAME>.*:name</NAME>
54
+                  <XML_ATTRIBUTE />
55
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
56
+                </AND>
57
+              </match>
58
+            </rule>
59
+          </section>
60
+          <section>
61
+            <rule>
62
+              <match>
63
+                <AND>
64
+                  <NAME>name</NAME>
65
+                  <XML_ATTRIBUTE />
66
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
67
+                </AND>
68
+              </match>
69
+            </rule>
70
+          </section>
71
+          <section>
72
+            <rule>
73
+              <match>
74
+                <AND>
75
+                  <NAME>style</NAME>
76
+                  <XML_ATTRIBUTE />
77
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
78
+                </AND>
79
+              </match>
80
+            </rule>
81
+          </section>
82
+          <section>
83
+            <rule>
84
+              <match>
85
+                <AND>
86
+                  <NAME>.*</NAME>
87
+                  <XML_ATTRIBUTE />
88
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
89
+                </AND>
90
+              </match>
91
+              <order>BY_NAME</order>
92
+            </rule>
93
+          </section>
94
+          <section>
95
+            <rule>
96
+              <match>
97
+                <AND>
98
+                  <NAME>.*</NAME>
99
+                  <XML_ATTRIBUTE />
100
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
101
+                </AND>
102
+              </match>
103
+              <order>ANDROID_ATTRIBUTE_ORDER</order>
104
+            </rule>
105
+          </section>
106
+          <section>
107
+            <rule>
108
+              <match>
109
+                <AND>
110
+                  <NAME>.*</NAME>
111
+                  <XML_ATTRIBUTE />
112
+                  <XML_NAMESPACE>.*</XML_NAMESPACE>
113
+                </AND>
114
+              </match>
115
+              <order>BY_NAME</order>
116
+            </rule>
117
+          </section>
118
+        </rules>
119
+      </arrangement>
120
+    </codeStyleSettings>
121
+    <codeStyleSettings language="kotlin">
122
+      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
123
+    </codeStyleSettings>
124
+  </code_scheme>
125
+</component>

+ 5 - 0
.idea/codeStyles/codeStyleConfig.xml View File

@@ -0,0 +1,5 @@
1
+<component name="ProjectCodeStyleConfiguration">
2
+  <state>
3
+    <option name="USE_PER_PROJECT_SETTINGS" value="true" />
4
+  </state>
5
+</component>

+ 9 - 0
.idea/dictionaries/dict.xml View File

@@ -0,0 +1,9 @@
1
+<component name="ProjectDictionaryState">
2
+  <dictionary name="dict">
3
+    <words>
4
+      <w>debouncing</w>
5
+      <w>requestable</w>
6
+      <w>requestor</w>
7
+    </words>
8
+  </dictionary>
9
+</component>

+ 6 - 0
.idea/encodings.xml View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project version="4">
3
+  <component name="Encoding">
4
+    <file url="PROJECT" charset="UTF-8" />
5
+  </component>
6
+</project>

+ 19 - 0
.idea/gradle.xml View File

@@ -0,0 +1,19 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project version="4">
3
+  <component name="GradleSettings">
4
+    <option name="linkedExternalProjectsSettings">
5
+      <GradleProjectSettings>
6
+        <option name="distributionType" value="DEFAULT_WRAPPED" />
7
+        <option name="externalProjectPath" value="$PROJECT_DIR$" />
8
+        <option name="modules">
9
+          <set>
10
+            <option value="$PROJECT_DIR$" />
11
+            <option value="$PROJECT_DIR$/app" />
12
+          </set>
13
+        </option>
14
+        <option name="resolveModulePerSourceSet" value="false" />
15
+        <option name="testRunner" value="PLATFORM" />
16
+      </GradleProjectSettings>
17
+    </option>
18
+  </component>
19
+</project>

+ 9 - 0
.idea/misc.xml View File

@@ -0,0 +1,9 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project version="4">
3
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
4
+    <output url="file://$PROJECT_DIR$/build/classes" />
5
+  </component>
6
+  <component name="ProjectType">
7
+    <option name="id" value="Android" />
8
+  </component>
9
+</project>

+ 12 - 0
.idea/runConfigurations.xml View File

@@ -0,0 +1,12 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project version="4">
3
+  <component name="RunConfigurationProducerService">
4
+    <option name="ignoredProducers">
5
+      <set>
6
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
7
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
8
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
9
+      </set>
10
+    </option>
11
+  </component>
12
+</project>

+ 6 - 0
.idea/vcs.xml View File

@@ -0,0 +1,6 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project version="4">
3
+  <component name="VcsDirectoryMappings">
4
+    <mapping directory="" vcs="Git" />
5
+  </component>
6
+</project>

+ 674 - 0
LICENSE View File

@@ -0,0 +1,674 @@
1
+                    GNU GENERAL PUBLIC LICENSE
2
+                       Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+                            Preamble
9
+
10
+  The GNU General Public License is a free, copyleft license for
11
+software and other kinds of works.
12
+
13
+  The licenses for most software and other practical works are designed
14
+to take away your freedom to share and change the works.  By contrast,
15
+the GNU General Public License is intended to guarantee your freedom to
16
+share and change all versions of a program--to make sure it remains free
17
+software for all its users.  We, the Free Software Foundation, use the
18
+GNU General Public License for most of our software; it applies also to
19
+any other work released this way by its authors.  You can apply it to
20
+your programs, too.
21
+
22
+  When we speak of free software, we are referring to freedom, not
23
+price.  Our General Public Licenses are designed to make sure that you
24
+have the freedom to distribute copies of free software (and charge for
25
+them if you wish), that you receive source code or can get it if you
26
+want it, that you can change the software or use pieces of it in new
27
+free programs, and that you know you can do these things.
28
+
29
+  To protect your rights, we need to prevent others from denying you
30
+these rights or asking you to surrender the rights.  Therefore, you have
31
+certain responsibilities if you distribute copies of the software, or if
32
+you modify it: responsibilities to respect the freedom of others.
33
+
34
+  For example, if you distribute copies of such a program, whether
35
+gratis or for a fee, you must pass on to the recipients the same
36
+freedoms that you received.  You must make sure that they, too, receive
37
+or can get the source code.  And you must show them these terms so they
38
+know their rights.
39
+
40
+  Developers that use the GNU GPL protect your rights with two steps:
41
+(1) assert copyright on the software, and (2) offer you this License
42
+giving you legal permission to copy, distribute and/or modify it.
43
+
44
+  For the developers' and authors' protection, the GPL clearly explains
45
+that there is no warranty for this free software.  For both users' and
46
+authors' sake, the GPL requires that modified versions be marked as
47
+changed, so that their problems will not be attributed erroneously to
48
+authors of previous versions.
49
+
50
+  Some devices are designed to deny users access to install or run
51
+modified versions of the software inside them, although the manufacturer
52
+can do so.  This is fundamentally incompatible with the aim of
53
+protecting users' freedom to change the software.  The systematic
54
+pattern of such abuse occurs in the area of products for individuals to
55
+use, which is precisely where it is most unacceptable.  Therefore, we
56
+have designed this version of the GPL to prohibit the practice for those
57
+products.  If such problems arise substantially in other domains, we
58
+stand ready to extend this provision to those domains in future versions
59
+of the GPL, as needed to protect the freedom of users.
60
+
61
+  Finally, every program is threatened constantly by software patents.
62
+States should not allow patents to restrict development and use of
63
+software on general-purpose computers, but in those that do, we wish to
64
+avoid the special danger that patents applied to a free program could
65
+make it effectively proprietary.  To prevent this, the GPL assures that
66
+patents cannot be used to render the program non-free.
67
+
68
+  The precise terms and conditions for copying, distribution and
69
+modification follow.
70
+
71
+                       TERMS AND CONDITIONS
72
+
73
+  0. Definitions.
74
+
75
+  "This License" refers to version 3 of the GNU General Public License.
76
+
77
+  "Copyright" also means copyright-like laws that apply to other kinds of
78
+works, such as semiconductor masks.
79
+
80
+  "The Program" refers to any copyrightable work licensed under this
81
+License.  Each licensee is addressed as "you".  "Licensees" and
82
+"recipients" may be individuals or organizations.
83
+
84
+  To "modify" a work means to copy from or adapt all or part of the work
85
+in a fashion requiring copyright permission, other than the making of an
86
+exact copy.  The resulting work is called a "modified version" of the
87
+earlier work or a work "based on" the earlier work.
88
+
89
+  A "covered work" means either the unmodified Program or a work based
90
+on the Program.
91
+
92
+  To "propagate" a work means to do anything with it that, without
93
+permission, would make you directly or secondarily liable for
94
+infringement under applicable copyright law, except executing it on a
95
+computer or modifying a private copy.  Propagation includes copying,
96
+distribution (with or without modification), making available to the
97
+public, and in some countries other activities as well.
98
+
99
+  To "convey" a work means any kind of propagation that enables other
100
+parties to make or receive copies.  Mere interaction with a user through
101
+a computer network, with no transfer of a copy, is not conveying.
102
+
103
+  An interactive user interface displays "Appropriate Legal Notices"
104
+to the extent that it includes a convenient and prominently visible
105
+feature that (1) displays an appropriate copyright notice, and (2)
106
+tells the user that there is no warranty for the work (except to the
107
+extent that warranties are provided), that licensees may convey the
108
+work under this License, and how to view a copy of this License.  If
109
+the interface presents a list of user commands or options, such as a
110
+menu, a prominent item in the list meets this criterion.
111
+
112
+  1. Source Code.
113
+
114
+  The "source code" for a work means the preferred form of the work
115
+for making modifications to it.  "Object code" means any non-source
116
+form of a work.
117
+
118
+  A "Standard Interface" means an interface that either is an official
119
+standard defined by a recognized standards body, or, in the case of
120
+interfaces specified for a particular programming language, one that
121
+is widely used among developers working in that language.
122
+
123
+  The "System Libraries" of an executable work include anything, other
124
+than the work as a whole, that (a) is included in the normal form of
125
+packaging a Major Component, but which is not part of that Major
126
+Component, and (b) serves only to enable use of the work with that
127
+Major Component, or to implement a Standard Interface for which an
128
+implementation is available to the public in source code form.  A
129
+"Major Component", in this context, means a major essential component
130
+(kernel, window system, and so on) of the specific operating system
131
+(if any) on which the executable work runs, or a compiler used to
132
+produce the work, or an object code interpreter used to run it.
133
+
134
+  The "Corresponding Source" for a work in object code form means all
135
+the source code needed to generate, install, and (for an executable
136
+work) run the object code and to modify the work, including scripts to
137
+control those activities.  However, it does not include the work's
138
+System Libraries, or general-purpose tools or generally available free
139
+programs which are used unmodified in performing those activities but
140
+which are not part of the work.  For example, Corresponding Source
141
+includes interface definition files associated with source files for
142
+the work, and the source code for shared libraries and dynamically
143
+linked subprograms that the work is specifically designed to require,
144
+such as by intimate data communication or control flow between those
145
+subprograms and other parts of the work.
146
+
147
+  The Corresponding Source need not include anything that users
148
+can regenerate automatically from other parts of the Corresponding
149
+Source.
150
+
151
+  The Corresponding Source for a work in source code form is that
152
+same work.
153
+
154
+  2. Basic Permissions.
155
+
156
+  All rights granted under this License are granted for the term of
157
+copyright on the Program, and are irrevocable provided the stated
158
+conditions are met.  This License explicitly affirms your unlimited
159
+permission to run the unmodified Program.  The output from running a
160
+covered work is covered by this License only if the output, given its
161
+content, constitutes a covered work.  This License acknowledges your
162
+rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+  You may make, run and propagate covered works that you do not
165
+convey, without conditions so long as your license otherwise remains
166
+in force.  You may convey covered works to others for the sole purpose
167
+of having them make modifications exclusively for you, or provide you
168
+with facilities for running those works, provided that you comply with
169
+the terms of this License in conveying all material for which you do
170
+not control copyright.  Those thus making or running the covered works
171
+for you must do so exclusively on your behalf, under your direction
172
+and control, on terms that prohibit them from making any copies of
173
+your copyrighted material outside their relationship with you.
174
+
175
+  Conveying under any other circumstances is permitted solely under
176
+the conditions stated below.  Sublicensing is not allowed; section 10
177
+makes it unnecessary.
178
+
179
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+  No covered work shall be deemed part of an effective technological
182
+measure under any applicable law fulfilling obligations under article
183
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+similar laws prohibiting or restricting circumvention of such
185
+measures.
186
+
187
+  When you convey a covered work, you waive any legal power to forbid
188
+circumvention of technological measures to the extent such circumvention
189
+is effected by exercising rights under this License with respect to
190
+the covered work, and you disclaim any intention to limit operation or
191
+modification of the work as a means of enforcing, against the work's
192
+users, your or third parties' legal rights to forbid circumvention of
193
+technological measures.
194
+
195
+  4. Conveying Verbatim Copies.
196
+
197
+  You may convey verbatim copies of the Program's source code as you
198
+receive it, in any medium, provided that you conspicuously and
199
+appropriately publish on each copy an appropriate copyright notice;
200
+keep intact all notices stating that this License and any
201
+non-permissive terms added in accord with section 7 apply to the code;
202
+keep intact all notices of the absence of any warranty; and give all
203
+recipients a copy of this License along with the Program.
204
+
205
+  You may charge any price or no price for each copy that you convey,
206
+and you may offer support or warranty protection for a fee.
207
+
208
+  5. Conveying Modified Source Versions.
209
+
210
+  You may convey a work based on the Program, or the modifications to
211
+produce it from the Program, in the form of source code under the
212
+terms of section 4, provided that you also meet all of these conditions:
213
+
214
+    a) The work must carry prominent notices stating that you modified
215
+    it, and giving a relevant date.
216
+
217
+    b) The work must carry prominent notices stating that it is
218
+    released under this License and any conditions added under section
219
+    7.  This requirement modifies the requirement in section 4 to
220
+    "keep intact all notices".
221
+
222
+    c) You must license the entire work, as a whole, under this
223
+    License to anyone who comes into possession of a copy.  This
224
+    License will therefore apply, along with any applicable section 7
225
+    additional terms, to the whole of the work, and all its parts,
226
+    regardless of how they are packaged.  This License gives no
227
+    permission to license the work in any other way, but it does not
228
+    invalidate such permission if you have separately received it.
229
+
230
+    d) If the work has interactive user interfaces, each must display
231
+    Appropriate Legal Notices; however, if the Program has interactive
232
+    interfaces that do not display Appropriate Legal Notices, your
233
+    work need not make them do so.
234
+
235
+  A compilation of a covered work with other separate and independent
236
+works, which are not by their nature extensions of the covered work,
237
+and which are not combined with it such as to form a larger program,
238
+in or on a volume of a storage or distribution medium, is called an
239
+"aggregate" if the compilation and its resulting copyright are not
240
+used to limit the access or legal rights of the compilation's users
241
+beyond what the individual works permit.  Inclusion of a covered work
242
+in an aggregate does not cause this License to apply to the other
243
+parts of the aggregate.
244
+
245
+  6. Conveying Non-Source Forms.
246
+
247
+  You may convey a covered work in object code form under the terms
248
+of sections 4 and 5, provided that you also convey the
249
+machine-readable Corresponding Source under the terms of this License,
250
+in one of these ways:
251
+
252
+    a) Convey the object code in, or embodied in, a physical product
253
+    (including a physical distribution medium), accompanied by the
254
+    Corresponding Source fixed on a durable physical medium
255
+    customarily used for software interchange.
256
+
257
+    b) Convey the object code in, or embodied in, a physical product
258
+    (including a physical distribution medium), accompanied by a
259
+    written offer, valid for at least three years and valid for as
260
+    long as you offer spare parts or customer support for that product
261
+    model, to give anyone who possesses the object code either (1) a
262
+    copy of the Corresponding Source for all the software in the
263
+    product that is covered by this License, on a durable physical
264
+    medium customarily used for software interchange, for a price no
265
+    more than your reasonable cost of physically performing this
266
+    conveying of source, or (2) access to copy the
267
+    Corresponding Source from a network server at no charge.
268
+
269
+    c) Convey individual copies of the object code with a copy of the
270
+    written offer to provide the Corresponding Source.  This
271
+    alternative is allowed only occasionally and noncommercially, and
272
+    only if you received the object code with such an offer, in accord
273
+    with subsection 6b.
274
+
275
+    d) Convey the object code by offering access from a designated
276
+    place (gratis or for a charge), and offer equivalent access to the
277
+    Corresponding Source in the same way through the same place at no
278
+    further charge.  You need not require recipients to copy the
279
+    Corresponding Source along with the object code.  If the place to
280
+    copy the object code is a network server, the Corresponding Source
281
+    may be on a different server (operated by you or a third party)
282
+    that supports equivalent copying facilities, provided you maintain
283
+    clear directions next to the object code saying where to find the
284
+    Corresponding Source.  Regardless of what server hosts the
285
+    Corresponding Source, you remain obligated to ensure that it is
286
+    available for as long as needed to satisfy these requirements.
287
+
288
+    e) Convey the object code using peer-to-peer transmission, provided
289
+    you inform other peers where the object code and Corresponding
290
+    Source of the work are being offered to the general public at no
291
+    charge under subsection 6d.
292
+
293
+  A separable portion of the object code, whose source code is excluded
294
+from the Corresponding Source as a System Library, need not be
295
+included in conveying the object code work.
296
+
297
+  A "User Product" is either (1) a "consumer product", which means any
298
+tangible personal property which is normally used for personal, family,
299
+or household purposes, or (2) anything designed or sold for incorporation
300
+into a dwelling.  In determining whether a product is a consumer product,
301
+doubtful cases shall be resolved in favor of coverage.  For a particular
302
+product received by a particular user, "normally used" refers to a
303
+typical or common use of that class of product, regardless of the status
304
+of the particular user or of the way in which the particular user
305
+actually uses, or expects or is expected to use, the product.  A product
306
+is a consumer product regardless of whether the product has substantial
307
+commercial, industrial or non-consumer uses, unless such uses represent
308
+the only significant mode of use of the product.
309
+
310
+  "Installation Information" for a User Product means any methods,
311
+procedures, authorization keys, or other information required to install
312
+and execute modified versions of a covered work in that User Product from
313
+a modified version of its Corresponding Source.  The information must
314
+suffice to ensure that the continued functioning of the modified object
315
+code is in no case prevented or interfered with solely because
316
+modification has been made.
317
+
318
+  If you convey an object code work under this section in, or with, or
319
+specifically for use in, a User Product, and the conveying occurs as
320
+part of a transaction in which the right of possession and use of the
321
+User Product is transferred to the recipient in perpetuity or for a
322
+fixed term (regardless of how the transaction is characterized), the
323
+Corresponding Source conveyed under this section must be accompanied
324
+by the Installation Information.  But this requirement does not apply
325
+if neither you nor any third party retains the ability to install
326
+modified object code on the User Product (for example, the work has
327
+been installed in ROM).
328
+
329
+  The requirement to provide Installation Information does not include a
330
+requirement to continue to provide support service, warranty, or updates
331
+for a work that has been modified or installed by the recipient, or for
332
+the User Product in which it has been modified or installed.  Access to a
333
+network may be denied when the modification itself materially and
334
+adversely affects the operation of the network or violates the rules and
335
+protocols for communication across the network.
336
+
337
+  Corresponding Source conveyed, and Installation Information provided,
338
+in accord with this section must be in a format that is publicly
339
+documented (and with an implementation available to the public in
340
+source code form), and must require no special password or key for
341
+unpacking, reading or copying.
342
+
343
+  7. Additional Terms.
344
+
345
+  "Additional permissions" are terms that supplement the terms of this
346
+License by making exceptions from one or more of its conditions.
347
+Additional permissions that are applicable to the entire Program shall
348
+be treated as though they were included in this License, to the extent
349
+that they are valid under applicable law.  If additional permissions
350
+apply only to part of the Program, that part may be used separately
351
+under those permissions, but the entire Program remains governed by
352
+this License without regard to the additional permissions.
353
+
354
+  When you convey a copy of a covered work, you may at your option
355
+remove any additional permissions from that copy, or from any part of
356
+it.  (Additional permissions may be written to require their own
357
+removal in certain cases when you modify the work.)  You may place
358
+additional permissions on material, added by you to a covered work,
359
+for which you have or can give appropriate copyright permission.
360
+
361
+  Notwithstanding any other provision of this License, for material you
362
+add to a covered work, you may (if authorized by the copyright holders of
363
+that material) supplement the terms of this License with terms:
364
+
365
+    a) Disclaiming warranty or limiting liability differently from the
366
+    terms of sections 15 and 16 of this License; or
367
+
368
+    b) Requiring preservation of specified reasonable legal notices or
369
+    author attributions in that material or in the Appropriate Legal
370
+    Notices displayed by works containing it; or
371
+
372
+    c) Prohibiting misrepresentation of the origin of that material, or
373
+    requiring that modified versions of such material be marked in
374
+    reasonable ways as different from the original version; or
375
+
376
+    d) Limiting the use for publicity purposes of names of licensors or
377
+    authors of the material; or
378
+
379
+    e) Declining to grant rights under trademark law for use of some
380
+    trade names, trademarks, or service marks; or
381
+
382
+    f) Requiring indemnification of licensors and authors of that
383
+    material by anyone who conveys the material (or modified versions of
384
+    it) with contractual assumptions of liability to the recipient, for
385
+    any liability that these contractual assumptions directly impose on
386
+    those licensors and authors.
387
+
388
+  All other non-permissive additional terms are considered "further
389
+restrictions" within the meaning of section 10.  If the Program as you
390
+received it, or any part of it, contains a notice stating that it is
391
+governed by this License along with a term that is a further
392
+restriction, you may remove that term.  If a license document contains
393
+a further restriction but permits relicensing or conveying under this
394
+License, you may add to a covered work material governed by the terms
395
+of that license document, provided that the further restriction does
396
+not survive such relicensing or conveying.
397
+
398
+  If you add terms to a covered work in accord with this section, you
399
+must place, in the relevant source files, a statement of the
400
+additional terms that apply to those files, or a notice indicating
401
+where to find the applicable terms.
402
+
403
+  Additional terms, permissive or non-permissive, may be stated in the
404
+form of a separately written license, or stated as exceptions;
405
+the above requirements apply either way.
406
+
407
+  8. Termination.
408
+
409
+  You may not propagate or modify a covered work except as expressly
410
+provided under this License.  Any attempt otherwise to propagate or
411
+modify it is void, and will automatically terminate your rights under
412
+this License (including any patent licenses granted under the third
413
+paragraph of section 11).
414
+
415
+  However, if you cease all violation of this License, then your
416
+license from a particular copyright holder is reinstated (a)
417
+provisionally, unless and until the copyright holder explicitly and
418
+finally terminates your license, and (b) permanently, if the copyright
419
+holder fails to notify you of the violation by some reasonable means
420
+prior to 60 days after the cessation.
421
+
422
+  Moreover, your license from a particular copyright holder is
423
+reinstated permanently if the copyright holder notifies you of the
424
+violation by some reasonable means, this is the first time you have
425
+received notice of violation of this License (for any work) from that
426
+copyright holder, and you cure the violation prior to 30 days after
427
+your receipt of the notice.
428
+
429
+  Termination of your rights under this section does not terminate the
430
+licenses of parties who have received copies or rights from you under
431
+this License.  If your rights have been terminated and not permanently
432
+reinstated, you do not qualify to receive new licenses for the same
433
+material under section 10.
434
+
435
+  9. Acceptance Not Required for Having Copies.
436
+
437
+  You are not required to accept this License in order to receive or
438
+run a copy of the Program.  Ancillary propagation of a covered work
439
+occurring solely as a consequence of using peer-to-peer transmission
440
+to receive a copy likewise does not require acceptance.  However,
441
+nothing other than this License grants you permission to propagate or
442
+modify any covered work.  These actions infringe copyright if you do
443
+not accept this License.  Therefore, by modifying or propagating a
444
+covered work, you indicate your acceptance of this License to do so.
445
+
446
+  10. Automatic Licensing of Downstream Recipients.
447
+
448
+  Each time you convey a covered work, the recipient automatically
449
+receives a license from the original licensors, to run, modify and
450
+propagate that work, subject to this License.  You are not responsible
451
+for enforcing compliance by third parties with this License.
452
+
453
+  An "entity transaction" is a transaction transferring control of an
454
+organization, or substantially all assets of one, or subdividing an
455
+organization, or merging organizations.  If propagation of a covered
456
+work results from an entity transaction, each party to that
457
+transaction who receives a copy of the work also receives whatever
458
+licenses to the work the party's predecessor in interest had or could
459
+give under the previous paragraph, plus a right to possession of the
460
+Corresponding Source of the work from the predecessor in interest, if
461
+the predecessor has it or can get it with reasonable efforts.
462
+
463
+  You may not impose any further restrictions on the exercise of the
464
+rights granted or affirmed under this License.  For example, you may
465
+not impose a license fee, royalty, or other charge for exercise of
466
+rights granted under this License, and you may not initiate litigation
467
+(including a cross-claim or counterclaim in a lawsuit) alleging that
468
+any patent claim is infringed by making, using, selling, offering for
469
+sale, or importing the Program or any portion of it.
470
+
471
+  11. Patents.
472
+
473
+  A "contributor" is a copyright holder who authorizes use under this
474
+License of the Program or a work on which the Program is based.  The
475
+work thus licensed is called the contributor's "contributor version".
476
+
477
+  A contributor's "essential patent claims" are all patent claims
478
+owned or controlled by the contributor, whether already acquired or
479
+hereafter acquired, that would be infringed by some manner, permitted
480
+by this License, of making, using, or selling its contributor version,
481
+but do not include claims that would be infringed only as a
482
+consequence of further modification of the contributor version.  For
483
+purposes of this definition, "control" includes the right to grant
484
+patent sublicenses in a manner consistent with the requirements of
485
+this License.
486
+
487
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+patent license under the contributor's essential patent claims, to
489
+make, use, sell, offer for sale, import and otherwise run, modify and
490
+propagate the contents of its contributor version.
491
+
492
+  In the following three paragraphs, a "patent license" is any express
493
+agreement or commitment, however denominated, not to enforce a patent
494
+(such as an express permission to practice a patent or covenant not to
495
+sue for patent infringement).  To "grant" such a patent license to a
496
+party means to make such an agreement or commitment not to enforce a
497
+patent against the party.
498
+
499
+  If you convey a covered work, knowingly relying on a patent license,
500
+and the Corresponding Source of the work is not available for anyone
501
+to copy, free of charge and under the terms of this License, through a
502
+publicly available network server or other readily accessible means,
503
+then you must either (1) cause the Corresponding Source to be so
504
+available, or (2) arrange to deprive yourself of the benefit of the
505
+patent license for this particular work, or (3) arrange, in a manner
506
+consistent with the requirements of this License, to extend the patent
507
+license to downstream recipients.  "Knowingly relying" means you have
508
+actual knowledge that, but for the patent license, your conveying the
509
+covered work in a country, or your recipient's use of the covered work
510
+in a country, would infringe one or more identifiable patents in that
511
+country that you have reason to believe are valid.
512
+
513
+  If, pursuant to or in connection with a single transaction or
514
+arrangement, you convey, or propagate by procuring conveyance of, a
515
+covered work, and grant a patent license to some of the parties
516
+receiving the covered work authorizing them to use, propagate, modify
517
+or convey a specific copy of the covered work, then the patent license
518
+you grant is automatically extended to all recipients of the covered
519
+work and works based on it.
520
+
521
+  A patent license is "discriminatory" if it does not include within
522
+the scope of its coverage, prohibits the exercise of, or is
523
+conditioned on the non-exercise of one or more of the rights that are
524
+specifically granted under this License.  You may not convey a covered
525
+work if you are a party to an arrangement with a third party that is
526
+in the business of distributing software, under which you make payment
527
+to the third party based on the extent of your activity of conveying
528
+the work, and under which the third party grants, to any of the
529
+parties who would receive the covered work from you, a discriminatory
530
+patent license (a) in connection with copies of the covered work
531
+conveyed by you (or copies made from those copies), or (b) primarily
532
+for and in connection with specific products or compilations that
533
+contain the covered work, unless you entered into that arrangement,
534
+or that patent license was granted, prior to 28 March 2007.
535
+
536
+  Nothing in this License shall be construed as excluding or limiting
537
+any implied license or other defenses to infringement that may
538
+otherwise be available to you under applicable patent law.
539
+
540
+  12. No Surrender of Others' Freedom.
541
+
542
+  If conditions are imposed on you (whether by court order, agreement or
543
+otherwise) that contradict the conditions of this License, they do not
544
+excuse you from the conditions of this License.  If you cannot convey a
545
+covered work so as to satisfy simultaneously your obligations under this
546
+License and any other pertinent obligations, then as a consequence you may
547
+not convey it at all.  For example, if you agree to terms that obligate you
548
+to collect a royalty for further conveying from those to whom you convey
549
+the Program, the only way you could satisfy both those terms and this
550
+License would be to refrain entirely from conveying the Program.
551
+
552
+  13. Use with the GNU Affero General Public License.
553
+
554
+  Notwithstanding any other provision of this License, you have
555
+permission to link or combine any covered work with a work licensed
556
+under version 3 of the GNU Affero General Public License into a single
557
+combined work, and to convey the resulting work.  The terms of this
558
+License will continue to apply to the part which is the covered work,
559
+but the special requirements of the GNU Affero General Public License,
560
+section 13, concerning interaction through a network will apply to the
561
+combination as such.
562
+
563
+  14. Revised Versions of this License.
564
+
565
+  The Free Software Foundation may publish revised and/or new versions of
566
+the GNU General Public License from time to time.  Such new versions will
567
+be similar in spirit to the present version, but may differ in detail to
568
+address new problems or concerns.
569
+
570
+  Each version is given a distinguishing version number.  If the
571
+Program specifies that a certain numbered version of the GNU General
572
+Public License "or any later version" applies to it, you have the
573
+option of following the terms and conditions either of that numbered
574
+version or of any later version published by the Free Software
575
+Foundation.  If the Program does not specify a version number of the
576
+GNU General Public License, you may choose any version ever published
577
+by the Free Software Foundation.
578
+
579
+  If the Program specifies that a proxy can decide which future
580
+versions of the GNU General Public License can be used, that proxy's
581
+public statement of acceptance of a version permanently authorizes you
582
+to choose that version for the Program.
583
+
584
+  Later license versions may give you additional or different
585
+permissions.  However, no additional obligations are imposed on any
586
+author or copyright holder as a result of your choosing to follow a
587
+later version.
588
+
589
+  15. Disclaimer of Warranty.
590
+
591
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+  16. Limitation of Liability.
601
+
602
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+SUCH DAMAGES.
611
+
612
+  17. Interpretation of Sections 15 and 16.
613
+
614
+  If the disclaimer of warranty and limitation of liability provided
615
+above cannot be given local legal effect according to their terms,
616
+reviewing courts shall apply local law that most closely approximates
617
+an absolute waiver of all civil liability in connection with the
618
+Program, unless a warranty or assumption of liability accompanies a
619
+copy of the Program in return for a fee.
620
+
621
+                     END OF TERMS AND CONDITIONS
622
+
623
+            How to Apply These Terms to Your New Programs
624
+
625
+  If you develop a new program, and you want it to be of the greatest
626
+possible use to the public, the best way to achieve this is to make it
627
+free software which everyone can redistribute and change under these terms.
628
+
629
+  To do so, attach the following notices to the program.  It is safest
630
+to attach them to the start of each source file to most effectively
631
+state the exclusion of warranty; and each file should have at least
632
+the "copyright" line and a pointer to where the full notice is found.
633
+
634
+    <one line to give the program's name and a brief idea of what it does.>
635
+    Copyright (C) <year>  <name of author>
636
+
637
+    This program is free software: you can redistribute it and/or modify
638
+    it under the terms of the GNU General Public License as published by
639
+    the Free Software Foundation, either version 3 of the License, or
640
+    (at your option) any later version.
641
+
642
+    This program is distributed in the hope that it will be useful,
643
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
645
+    GNU General Public License for more details.
646
+
647
+    You should have received a copy of the GNU General Public License
648
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
649
+
650
+Also add information on how to contact you by electronic and paper mail.
651
+
652
+  If the program does terminal interaction, make it output a short
653
+notice like this when it starts in an interactive mode:
654
+
655
+    <program>  Copyright (C) <year>  <name of author>
656
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+    This is free software, and you are welcome to redistribute it
658
+    under certain conditions; type `show c' for details.
659
+
660
+The hypothetical commands `show w' and `show c' should show the appropriate
661
+parts of the General Public License.  Of course, your program's commands
662
+might be different; for a GUI interface, you would use an "about box".
663
+
664
+  You should also get your employer (if you work as a programmer) or school,
665
+if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+For more information on this, and how to apply and follow the GNU GPL, see
667
+<https://www.gnu.org/licenses/>.
668
+
669
+  The GNU General Public License does not permit incorporating your program
670
+into proprietary programs.  If your program is a subroutine library, you
671
+may consider it more useful to permit linking proprietary applications with
672
+the library.  If this is what you want to do, use the GNU Lesser General
673
+Public License instead of this License.  But first, please read
674
+<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ 50 - 0
README.md View File

@@ -0,0 +1,50 @@
1
+![icon](https://r-a-d.io/assets/logo_image_small.png)![logo](https://r-a-d.io/assets/logotitle_2.png)
2
+
3
+# R/a/dio, the brand-new Android app!
4
+
5
+R/a/dio is a webradio that was founded 7+ years ago with the intention of bringing you a stream of (mostly) high quality anime music, and we keep that up to this day! You can always drop by and visit our website https://r-a-d.io for more information.
6
+
7
+![the gif](./demo.gif)
8
+
9
+### Features
10
+
11
+- Listen to R/a/dio!
12
+- Fine-tune the volume in the app to go lower than the lowest volume of Android!
13
+- Adapt to all screens: small phone, big tablet, horizontal, vertical, split screen!
14
+- Start and stop the stream by headphones plugging/unplugging, or with a bluetooth headset!
15
+- Check last played and queued tracks!
16
+- Request any title to the AFK streamer Hanyuu-sama!
17
+- Display and request your favorite tracks! There's even a random-request button (**.ra f**) available!
18
+- Chat in the IRC with the embedded WebIRC!
19
+- Wake up with the sound of R/a/dio with the built-in Alarm Clock feature! Don't worry: if there is no network, it will play a default sound instead.
20
+- Never miss a stream with the Streamer Notification Service! (**Warning: this feature polls the server regularly and consumes battery. It is MUCH MORE RECOMMENDED to register to Hanyuu-sama's updates on Twitter and use the Twitter app. But if for some reason you don't (or can't) use the Twitter App, this should get you covered.**)
21
+- Supports lastFM, LibreFM and Listenbrainz scrobblers with [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble)
22
+- **[v1.1]** Snooze the alarm! You can set up the snooze duration you want, or avoid being tempted and disable snooze altogether. When ringing, a special notification design will display bigger buttons with text instead of icons.
23
+- **[v1.1]** Sleep with the sound of R/a/dio! You can set up a timer to stop the app after any amount of minutes. When the timer approaches, the sound will gradually fade out.
24
+
25
+As always, thanks for listening!
26
+
27
+
28
+# Releases
29
+
30
+## Release 1.1.0
31
+
32
+Incoming! The date will be announced when it reaches the Play Store. The version number may be different, probably a v2.0.0 considering it's a full rewrite of the previous app.
33
+
34
+Features added: 
35
+- Alarm snooze
36
+- Sleep timer
37
+- Special notification design for alarm
38
+- Added pull-to-refresh on favorites
39
+- Added link to help on how to use favorites in IRC
40
+- Added settings to control API fetch frequency when playback is stopped
41
+
42
+Bug fixes:
43
+- Better handling of queue, avoid duplicates
44
+- Should handle correctly when the stream is down ("ed" playing)
45
+- Update the streamer picture in notification at startup
46
+- UI updates correctly when app is opened after alarm wake-up
47
+- Backup alarm sound is now correctly triggered only when necessary
48
+
49
+## Release 1.0
50
+- will probably stay undisclosed, as the release will surely be based on what I call 1.1.0

+ 1 - 0
app/.gitignore View File

@@ -0,0 +1 @@
1
+/build

+ 110 - 0
app/build.gradle View File

@@ -0,0 +1,110 @@
1
+apply plugin: 'com.android.application'
2
+
3
+apply plugin: 'kotlin-android'
4
+apply plugin: 'kotlin-android-extensions'
5
+apply plugin: 'kotlin-kapt'
6
+
7
+configurations.all {
8
+    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
9
+        def requested = details.requested
10
+        if (requested.group == "androidx") {
11
+            if (!requested.name.startsWith("multidex")) {
12
+                details.useVersion "${targetSdk}.+"
13
+            }
14
+        }
15
+    }
16
+}
17
+
18
+android {
19
+    compileSdkVersion 29
20
+    buildToolsVersion "29.0.2"
21
+    compileOptions {
22
+        sourceCompatibility JavaVersion.VERSION_1_8
23
+        targetCompatibility JavaVersion.VERSION_1_8
24
+    }
25
+    kotlinOptions {
26
+        jvmTarget = "1.8"
27
+    }
28
+    defaultConfig {
29
+        applicationId "io.r_a_d.radio2"
30
+        minSdkVersion 16
31
+        targetSdkVersion 29
32
+        versionCode 2
33
+        versionName "1.1dev"
34
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
35
+        vectorDrawables.useSupportLibrary = true
36
+    }
37
+    buildTypes {
38
+        release {
39
+            minifyEnabled false
40
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
41
+        }
42
+    }
43
+}
44
+
45
+dependencies {
46
+    implementation fileTree(dir: 'libs', include: ['*.jar'])
47
+    /*
48
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
49
+    implementation 'androidx.appcompat:appcompat:1.1.0'
50
+    implementation 'androidx.core:core-ktx:1.1.0'
51
+    implementation 'com.google.android.material:material:1.0.0'
52
+    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
53
+    implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0'
54
+    implementation 'androidx.navigation:navigation-ui-ktx:2.1.0'
55
+    implementation 'androidx.vectordrawable:vectordrawable:1.1.0'
56
+
57
+    implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
58
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
59
+
60
+    testImplementation 'junit:junit:4.12'
61
+    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
62
+    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
63
+
64
+     */
65
+
66
+    implementation "androidx.media:media:1.1.0"
67
+    implementation 'com.google.android.exoplayer:exoplayer:2.10.6'
68
+    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
69
+    implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
70
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
71
+
72
+    implementation 'androidx.recyclerview:recyclerview:1.0.0'
73
+    implementation 'androidx.preference:preference:1.1.0'
74
+
75
+    // jsoup HTML parser library @ https://jsoup.org/
76
+    // implementation 'org.jsoup:jsoup:1.12.1'
77
+
78
+    def work_version = "2.2.0"
79
+    implementation "androidx.work:work-runtime-ktx:$work_version"
80
+
81
+
82
+    // new implems
83
+    implementation deps.kotlin.stdlib
84
+    implementation deps.support.app_compat
85
+    implementation deps.support.design
86
+    implementation deps.support.core_ktx
87
+    implementation deps.constraint_layout
88
+    implementation deps.arch_core.runtime
89
+
90
+    // Navigation
91
+    implementation deps.navigation.runtime_ktx
92
+    implementation deps.navigation.fragment_ktx
93
+    implementation deps.navigation.ui_ktx
94
+
95
+    // Android Testing Support Library's runner and rules
96
+
97
+    /*
98
+    androidTestImplementation deps.atsl.runner
99
+    androidTestImplementation deps.atsl.rules
100
+    androidTestImplementation deps.room.testing
101
+    androidTestImplementation deps.arch_core.testing
102
+
103
+     */
104
+
105
+    // Espresso UI Testing
106
+    androidTestImplementation deps.espresso.core
107
+    androidTestImplementation deps.espresso.contrib
108
+    androidTestImplementation deps.espresso.intents
109
+
110
+}

+ 21 - 0
app/proguard-rules.pro View File

@@ -0,0 +1,21 @@
1
+# Add project specific ProGuard rules here.
2
+# You can control the set of applied configuration files using the
3
+# proguardFiles setting in build.gradle.
4
+#
5
+# For more details, see
6
+#   http://developer.android.com/guide/developing/tools/proguard.html
7
+
8
+# If your project uses WebView with JS, uncomment the following
9
+# and specify the fully qualified class name to the JavaScript interface
10
+# class:
11
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12
+#   public *;
13
+#}
14
+
15
+# Uncomment this to preserve the line number information for
16
+# debugging stack traces.
17
+#-keepattributes SourceFile,LineNumberTable
18
+
19
+# If you keep the line number information, uncomment this to
20
+# hide the original source file name.
21
+#-renamesourcefileattribute SourceFile

+ 24 - 0
app/src/androidTest/java/io/r_a_d/radio2/ExampleInstrumentedTest.kt View File

@@ -0,0 +1,24 @@
1
+package io.r_a_d.radio2
2
+
3
+import androidx.test.platform.app.InstrumentationRegistry
4
+import androidx.test.ext.junit.runners.AndroidJUnit4
5
+
6
+import org.junit.Test
7
+import org.junit.runner.RunWith
8
+
9
+import org.junit.Assert.*
10
+
11
+/**
12
+ * Instrumented test, which will execute on an Android device.
13
+ *
14
+ * See [testing documentation](http://d.android.com/tools/testing).
15
+ */
16
+@RunWith(AndroidJUnit4::class)
17
+class ExampleInstrumentedTest {
18
+    @Test
19
+    fun useAppContext() {
20
+        // Context of the app under test.
21
+        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22
+        assertEquals("io.r_a_d.radio2", appContext.packageName)
23
+    }
24
+}

+ 75 - 0
app/src/main/AndroidManifest.xml View File

@@ -0,0 +1,75 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+    xmlns:tools="http://schemas.android.com/tools"
4
+    package="io.r_a_d.radio2">
5
+
6
+    <uses-permission android:name="android.permission.VIBRATE" />
7
+    <uses-permission android:name="android.permission.INTERNET" />
8
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
9
+    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
10
+    <uses-permission android:name="android.permission.BLUETOOTH" />
11
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
12
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- needed for API 28 -->
13
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
14
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
15
+    <uses-permission android:name="android.permission.SET_ALARM"/>
16
+
17
+    <application
18
+        android:allowBackup="true"
19
+        android:icon="@mipmap/ic_launcher"
20
+        android:roundIcon="@mipmap/ic_launcher_round"
21
+        android:label="@string/app_name"
22
+        android:supportsRtl="true"
23
+        android:theme="@style/AppTheme.Launcher"
24
+        tools:ignore="AllowBackup,GoogleAppIndexingWarning">
25
+
26
+        <service
27
+            android:name=".RadioService"
28
+            android:enabled="true"
29
+            android:exported="true">
30
+            <intent-filter>
31
+                <action android:name="android.media.browse.MediaBrowserService" />
32
+            </intent-filter>
33
+        </service>
34
+        <service android:name=".streamerNotificationService.StreamerMonitorService"
35
+            android:enabled="true"
36
+            android:exported="true"/>
37
+
38
+        <receiver android:name=".BootBroadcastReceiver"
39
+            android:directBootAware="true"
40
+            tools:targetApi="n">
41
+            <intent-filter>
42
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
43
+                <action android:name="io.r_a_d.radio2.PLAY_OR_FALLBACK" />
44
+                <action android:name="io.r_a_d.radio2.NOTIFY" /> <!-- this is for Snooze -->
45
+                <category android:name="android.intent.category.DEFAULT"/>
46
+            </intent-filter>
47
+        </receiver>
48
+
49
+        <activity
50
+            android:name=".MainActivity"
51
+            android:configChanges="orientation|screenSize"
52
+            android:label="@string/app_name"
53
+            android:launchMode="singleTask"
54
+            android:screenOrientation="fullSensor">
55
+            <intent-filter>
56
+                <action android:name="android.intent.action.MAIN" />
57
+
58
+                <category android:name="android.intent.category.LAUNCHER" />
59
+            </intent-filter>
60
+        </activity>
61
+        <activity
62
+            android:name=".ParametersActivity"
63
+            android:configChanges="orientation|screenSize"
64
+            android:launchMode="singleTask"
65
+            android:parentActivityName=".MainActivity"
66
+            android:screenOrientation="fullSensor" />
67
+
68
+        <receiver android:name="androidx.media.session.MediaButtonReceiver">
69
+            <intent-filter>
70
+                <action android:name="android.intent.action.MEDIA_BUTTON" />
71
+            </intent-filter>
72
+        </receiver>
73
+    </application>
74
+
75
+</manifest>

+ 23 - 0
app/src/main/assets/chat.html View File

@@ -0,0 +1,23 @@
1
+<!DOCTYPE html>
2
+<html lang="fr">
3
+
4
+<head>
5
+  <meta charset="utf-8">
6
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+  <meta name="viewport" content="width=device-width, initial-scale=1.0,  maximum-scale=1.0, user-scalable=no">
8
+  <!-- seems like the font cannot be changed in KiwiIRC without writing a full theme, so loading the CSS below is useless-->
9
+  <!--
10
+  <link rel="stylesheet" href="stylesheet.css" type="text/css" charset="utf-8" /> -->
11
+
12
+</head>
13
+
14
+<body>
15
+  <div style="height: auto">
16
+    <iframe src="https://kiwiirc.com/nextclient/?theme=dark#irc://irc.rizon.net:+6697/#r/a/dio"
17
+      style="border:0; width:100%; height:100%; position: absolute; top: 0px; left: 0px">
18
+    </iframe>
19
+  </div>
20
+
21
+</body>
22
+
23
+</html>

BIN
app/src/main/assets/the_stream_is_down.mp3 View File


BIN
app/src/main/ic_launcher-web.png View File


+ 27 - 0
app/src/main/java/io/r_a_d/radio2/Actions.kt View File

@@ -0,0 +1,27 @@
1
+package io.r_a_d.radio2
2
+
3
+enum class Actions
4
+{
5
+    PLAY,
6
+    STOP,
7
+    PAUSE,
8
+    VOLUME,
9
+    KILL,
10
+    NOTIFY,
11
+    PLAY_OR_FALLBACK,
12
+    FADE_OUT,
13
+    CANCEL_FADE_OUT,
14
+    SNOOZE
15
+}
16
+
17
+enum class ActionOnError {
18
+    RESET,
19
+    NOTIFY
20
+}
21
+
22
+enum class ActionOpenParam {
23
+    SLEEP,
24
+    ALARM,
25
+    CUSTOMIZE,
26
+    STREAMER_NOTIFICATION_SERVICE
27
+}

+ 69 - 0
app/src/main/java/io/r_a_d/radio2/Async.kt View File

@@ -0,0 +1,69 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.os.AsyncTask
4
+import android.util.Log
5
+import io.r_a_d.radio2.playerstore.PlayerStore
6
+
7
+class Async(val handler: (Any?) -> Any?, val post: (Any?) -> Unit = {},
8
+            private val actionOnError: ActionOnError = ActionOnError.RESET, private val parameters: Any? = null) :
9
+    AsyncTask<Any, Void, Any>() {
10
+
11
+    init {
12
+        try {
13
+            execute()
14
+        } catch (e: Exception)
15
+        {
16
+            Log.d(tag,e.toString())
17
+        }
18
+    }
19
+
20
+    private fun onException(e: java.lang.Exception) {
21
+        when(actionOnError)
22
+        {
23
+            ActionOnError.RESET -> resetPlayerStateOnNetworkError()
24
+            ActionOnError.NOTIFY -> return
25
+        }
26
+    }
27
+
28
+    private fun resetPlayerStateOnNetworkError() {
29
+        var storeReset = false
30
+
31
+        // checking isInitialized avoids setting streamerName multiple times, so it avoids a callback loop.
32
+        if (PlayerStore.instance.isInitialized)
33
+        {
34
+            PlayerStore.instance.currentSong.artist.postValue("")
35
+            PlayerStore.instance.isInitialized = false
36
+            PlayerStore.instance.streamerName.postValue("")
37
+            PlayerStore.instance.queue.clear()
38
+            PlayerStore.instance.lp.clear()
39
+            PlayerStore.instance.isQueueUpdated.postValue(true)
40
+            PlayerStore.instance.isLpUpdated.postValue(true)
41
+            // safe-update for the title avoids callback loop too.
42
+            if (PlayerStore.instance.currentSong.title.value != noConnectionValue)
43
+                PlayerStore.instance.currentSong.title.postValue(noConnectionValue)
44
+            storeReset = true
45
+        }
46
+
47
+
48
+        Log.d(tag, "fallback for no network. Store reset : $storeReset")
49
+    }
50
+
51
+    override fun doInBackground(vararg params: Any?): Any? {
52
+        try {
53
+            return handler(parameters)
54
+        } catch (e: Exception) {
55
+            Log.d(tag,e.toString())
56
+            onException(e)
57
+        }
58
+        return null
59
+    }
60
+
61
+    override fun onPostExecute(result: Any?) {
62
+        try {
63
+            post(result)
64
+        } catch (e: Exception) {
65
+            Log.d(tag,e.toString())
66
+            onException(e)
67
+        }
68
+    }
69
+}

+ 85 - 0
app/src/main/java/io/r_a_d/radio2/BaseActivity.kt View File

@@ -0,0 +1,85 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.content.Intent
4
+import android.os.Bundle
5
+import android.util.Log
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import android.view.ViewTreeObserver
9
+
10
+import androidx.appcompat.app.AppCompatActivity
11
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
12
+import androidx.preference.PreferenceManager
13
+import com.google.android.material.bottomnavigation.BottomNavigationView
14
+
15
+abstract class BaseActivity : AppCompatActivity() {
16
+
17
+    private val keyboardLayoutListener : ViewTreeObserver.OnGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
18
+        val viewHeight = (rootLayout?.rootView?.height ?: 0)
19
+        val viewWidth = (rootLayout?.rootView?.width ?: 0)
20
+
21
+        val height =  ((rootLayout?.height ?: 0))
22
+        val width =  ((rootLayout?.width ?: 0))
23
+
24
+        Log.d(tag, "$viewWidth, $viewHeight, $width, $height, ${viewHeight.toDouble()/viewWidth.toDouble()}, ${height.toDouble()/width.toDouble()}")
25
+
26
+        val broadcastManager = LocalBroadcastManager.getInstance(this@BaseActivity)
27
+        if(height <= viewHeight * 2 / 3 /*height.toDouble()/width.toDouble() < 1.20 */){
28
+            val keyboardHeight = viewHeight - height
29
+            onShowKeyboard(keyboardHeight)
30
+
31
+            val intent = Intent("KeyboardWillShow")
32
+            intent.putExtra("KeyboardHeight", keyboardHeight)
33
+            broadcastManager.sendBroadcast(intent)
34
+        } else {
35
+            onHideKeyboard()
36
+
37
+            val intent = Intent("KeyboardWillHide")
38
+            broadcastManager.sendBroadcast(intent)
39
+        }
40
+
41
+    }
42
+
43
+    private var keyboardListenersAttached = false
44
+    private var rootLayout: ViewGroup? = null
45
+
46
+
47
+    // keyboard stuff
48
+    private fun onShowKeyboard(keyboardHeight: Int) {
49
+        // do things when keyboard is shown
50
+        val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav)
51
+        bottomNavigationView.visibility = View.GONE
52
+        Log.d(tag, "bottomNav visibility set to GONE (height $keyboardHeight)")
53
+    }
54
+
55
+     private fun onHideKeyboard() {
56
+        // do things when keyboard is hidden
57
+        val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav)
58
+        bottomNavigationView.visibility = View.VISIBLE
59
+        Log.d(tag, "bottomNav visibility set to VISIBLE")
60
+    }
61
+
62
+    protected fun attachKeyboardListeners() {
63
+
64
+        if (keyboardListenersAttached) {
65
+            return
66
+        }
67
+
68
+        rootLayout = findViewById(R.id.rootLayout)
69
+        rootLayout!!.viewTreeObserver.addOnGlobalLayoutListener(keyboardLayoutListener)
70
+
71
+        keyboardListenersAttached = true
72
+    }
73
+
74
+    override fun onCreate(savedInstanceState: Bundle?) {
75
+        super.onCreate(savedInstanceState)
76
+        preferenceStore = PreferenceManager.getDefaultSharedPreferences(this)
77
+    }
78
+
79
+    override fun onDestroy() {
80
+        super.onDestroy()
81
+        if (keyboardListenersAttached) {
82
+            rootLayout?.viewTreeObserver?.removeOnGlobalLayoutListener(keyboardLayoutListener)
83
+        }
84
+    }
85
+}

+ 94 - 0
app/src/main/java/io/r_a_d/radio2/BaseNotification.kt View File

@@ -0,0 +1,94 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.app.Notification
4
+import android.app.NotificationChannel
5
+import android.app.NotificationManager
6
+import android.app.PendingIntent
7
+import android.content.Context
8
+import android.content.Intent
9
+import android.os.Build
10
+import androidx.annotation.RequiresApi
11
+import androidx.core.app.NotificationCompat
12
+
13
+abstract class BaseNotification(private val notificationChannelId: String,
14
+                                private val notificationChannel : Int,
15
+                                private val notificationId: Int,
16
+                                private val notificationImportance: Int
17
+                       ) {
18
+
19
+
20
+    // ########################################
21
+    // ########## BASE NOTIFICATION ###########
22
+    // ########################################
23
+
24
+    // Define the notification in android's swipe-down menu
25
+    lateinit var notification: Notification
26
+    protected lateinit var notificationManager: NotificationManager
27
+    protected lateinit var builder: NotificationCompat.Builder
28
+
29
+    @RequiresApi(api = Build.VERSION_CODES.O)
30
+    protected fun createNotificationChannel(c: Context): String {
31
+        val chanName = notificationChannel
32
+        val notificationChannelImportance =
33
+            when(notificationImportance) {
34
+                NotificationCompat.PRIORITY_LOW -> NotificationManager.IMPORTANCE_LOW
35
+                NotificationCompat.PRIORITY_DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT
36
+                NotificationCompat.PRIORITY_HIGH-> NotificationManager.IMPORTANCE_HIGH
37
+                NotificationCompat.PRIORITY_MAX -> NotificationManager.IMPORTANCE_MAX
38
+                NotificationCompat.PRIORITY_MIN -> NotificationManager.IMPORTANCE_MIN
39
+                else -> NotificationManager.IMPORTANCE_DEFAULT
40
+            }
41
+        val chan = NotificationChannel(this.notificationChannelId, c.getString(chanName), notificationChannelImportance)
42
+        chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
43
+        notificationManager.createNotificationChannel(chan)
44
+        return this.notificationChannelId
45
+    }
46
+
47
+    fun show()
48
+    {
49
+        notification = builder.build()
50
+        notificationManager.notify(notificationId, notification)
51
+    }
52
+
53
+    open fun create(c: Context) {
54
+        notificationManager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
55
+
56
+        val notificationIntent = Intent(c, MainActivity::class.java)
57
+        // The PendingIntent will launch the SAME activity
58
+        // thanks to the launchMode specified in the Manifest : android:launchMode="singleTop"
59
+        val pendingIntent = PendingIntent.getActivity(
60
+            c, 0,
61
+            notificationIntent, 0
62
+        )
63
+        var channelID = ""
64
+
65
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
66
+            channelID = createNotificationChannel(c)
67
+        }
68
+        builder = NotificationCompat.Builder(c, channelID)
69
+
70
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
71
+            builder.setSmallIcon(R.drawable.lollipop_logo)
72
+            builder.color = -0x20b3c6
73
+        } else {
74
+            builder.setSmallIcon(R.drawable.normal_logo)
75
+        }
76
+
77
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
78
+            builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
79
+        }
80
+
81
+        builder.priority = notificationImportance
82
+
83
+        // The PendingIntent will launch the SAME activity
84
+        // thanks to the launchMode specified in the Manifest : android:launchMode="singleTop"
85
+        builder.setContentIntent(pendingIntent)
86
+
87
+        builder.setColorized(true)
88
+    }
89
+
90
+    fun clear()
91
+    {
92
+        notificationManager.cancel(notificationId)
93
+    }
94
+}

+ 43 - 0
app/src/main/java/io/r_a_d/radio2/BootBroadcastReceiver.kt View File

@@ -0,0 +1,43 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.content.BroadcastReceiver
4
+import android.content.Context
5
+import android.content.Intent
6
+import android.os.Build
7
+import android.util.Log
8
+import androidx.preference.PreferenceManager
9
+import io.r_a_d.radio2.alarm.RadioAlarm
10
+import io.r_a_d.radio2.playerstore.PlayerStore
11
+import io.r_a_d.radio2.streamerNotificationService.WorkerStore
12
+import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor
13
+
14
+class BootBroadcastReceiver : BroadcastReceiver(){
15
+
16
+    override fun onReceive(context: Context, arg1: Intent) {
17
+        Log.d(tag, "Broadcast Receiver received $arg1")
18
+        // define preferenceStore for places of the program that needs to access Preferences without a context
19
+        preferenceStore = PreferenceManager.getDefaultSharedPreferences(context)
20
+
21
+        if (arg1.action == Intent.ACTION_BOOT_COMPLETED) {
22
+            WorkerStore.instance.init(context)
23
+            startStreamerMonitor(context) // will actually start it only if enabled in settings
24
+            RadioAlarm.instance.setNextAlarm(context) // schedule next alarm
25
+        }
26
+
27
+        if (arg1.getStringExtra("action") == "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}" )
28
+        {
29
+            RadioAlarm.instance.setNextAlarm(context) // schedule next alarm
30
+            if (PlayerStore.instance.streamerName.value.isNullOrBlank())
31
+                PlayerStore.instance.initPicture(context)
32
+            if (!PlayerStore.instance.isInitialized)
33
+                PlayerStore.instance.initApi()
34
+
35
+            val i = Intent(context, RadioService::class.java)
36
+            i.putExtra("action", Actions.PLAY_OR_FALLBACK.name)
37
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
38
+                context.startForegroundService(i)
39
+            else
40
+                context.startService(i)
41
+        }
42
+    }
43
+}

+ 284 - 0
app/src/main/java/io/r_a_d/radio2/MainActivity.kt View File

@@ -0,0 +1,284 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.os.Bundle
4
+import com.google.android.material.bottomnavigation.BottomNavigationView
5
+import androidx.navigation.ui.setupActionBarWithNavController
6
+import android.content.Intent
7
+import android.util.Log
8
+import android.view.Menu
9
+import androidx.appcompat.widget.Toolbar
10
+import androidx.core.content.res.ResourcesCompat
11
+import androidx.lifecycle.LiveData
12
+import androidx.lifecycle.Observer
13
+import androidx.navigation.NavController
14
+import io.r_a_d.radio2.playerstore.PlayerStore
15
+
16
+import java.util.Timer
17
+import android.view.MenuItem
18
+import androidx.preference.PreferenceManager
19
+import com.google.android.material.snackbar.Snackbar
20
+import io.r_a_d.radio2.alarm.RadioAlarm
21
+import io.r_a_d.radio2.streamerNotificationService.WorkerStore
22
+import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor
23
+import io.r_a_d.radio2.ui.songs.request.Requestor
24
+
25
+
26
+/* Log to file import
27
+import android.os.Environment
28
+import java.io.File
29
+import java.io.IOException
30
+*/
31
+
32
+class MainActivity : BaseActivity() {
33
+
34
+    private val clockTicker: Timer = Timer()
35
+    private var currentNavController: LiveData<NavController>? = null
36
+    private var isTimerStarted = false
37
+
38
+    /**
39
+     * Called on first creation and when restoring state.
40
+     */
41
+    private fun setupBottomNavigationBar() {
42
+        val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav)
43
+
44
+        //val navGraphIds = listOf(R.navigation.home, R.navigation.list, R.navigation.form)
45
+        val navGraphIds = listOf(R.navigation.navigation_nowplaying, R.navigation.navigation_songs,
46
+            R.navigation.navigation_news, R.navigation.navigation_chat)
47
+
48
+        // Setup the bottom navigation view with a list of navigation graphs
49
+        val controller = bottomNavigationView.setupWithNavController(
50
+            navGraphIds = navGraphIds,
51
+            fragmentManager = supportFragmentManager,
52
+            containerId = R.id.nav_host_container,
53
+            intent = intent
54
+        )
55
+
56
+        // Whenever the selected controller changes, setup the action bar.
57
+        controller.observe(this, Observer { navController ->
58
+            setupActionBarWithNavController(navController)
59
+        })
60
+        currentNavController = controller
61
+    }
62
+
63
+    override fun onSupportNavigateUp(): Boolean {
64
+        return currentNavController?.value?.navigateUp() ?: false
65
+    }
66
+
67
+    // #####################################
68
+    // ######### LIFECYCLE CALLBACKS #######
69
+    // #####################################
70
+
71
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
72
+        // Inflate the menu, this adds items to the action bar if it is present.
73
+        menuInflater.inflate(R.menu.toolbar_menu, menu)
74
+        return true
75
+    }
76
+
77
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
78
+        // Handle item selection
79
+        return when (item.itemId) {
80
+            R.id.action_refresh -> {
81
+                PlayerStore.instance.queue.clear()
82
+                PlayerStore.instance.lp.clear()
83
+                PlayerStore.instance.initApi()
84
+                Requestor.instance.initFavorites()
85
+                val s = Snackbar.make(findViewById(R.id.nav_host_container), "Refreshing data..." as CharSequence, Snackbar.LENGTH_LONG)
86
+                s.show()
87
+                true
88
+            }
89
+            R.id.action_settings -> {
90
+                val i = Intent(this, ParametersActivity::class.java)
91
+                startActivity(i)
92
+                true
93
+            }
94
+            R.id.action_sleep -> {
95
+                val i = Intent(this, ParametersActivity::class.java)
96
+                i.putExtra("action", ActionOpenParam.SLEEP.name) // TODO change value with Actions.something
97
+                startActivity(i)
98
+                true
99
+            }
100
+            R.id.action_alarm -> {
101
+                val i = Intent(this, ParametersActivity::class.java)
102
+                i.putExtra("action", ActionOpenParam.ALARM.name) // TODO change value with Actions.something
103
+                startActivity(i)
104
+                true
105
+            }
106
+
107
+            else -> super.onOptionsItemSelected(item)
108
+        }
109
+    }
110
+
111
+    override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
112
+        super.onRestoreInstanceState(savedInstanceState ?: Bundle())
113
+        // Now that BottomNavigationBar has restored its instance state
114
+        // and its selectedItemId, we can proceed with setting up the
115
+        // BottomNavigationBar with Navigation
116
+        setupBottomNavigationBar()
117
+    }
118
+
119
+    override fun onCreate(savedInstanceState: Bundle?) {
120
+        super.onCreate(savedInstanceState)
121
+
122
+        WorkerStore.instance.init(this)
123
+        startStreamerMonitor(this) // this checks the preferenceStore before actually starting a service, don't worry.
124
+
125
+        RadioAlarm.instance.cancelAlarm(c = this)
126
+        RadioAlarm.instance.setNextAlarm(c = this) // this checks the preferenceStore before actually setting an alarm, don't worry.
127
+
128
+        // initialize programmatically accessible colors
129
+        colorBlue = ResourcesCompat.getColor(resources, R.color.bluereq, null)
130
+        colorWhited = ResourcesCompat.getColor(resources, R.color.whited, null)
131
+        colorGreenList = (ResourcesCompat.getColorStateList(resources, R.color.button_green, null))
132
+        colorRedList = (ResourcesCompat.getColorStateList(resources, R.color.button_red, null))
133
+        colorGreenListCompat = (ResourcesCompat.getColorStateList(resources, R.color.button_green_compat, null))
134
+
135
+        PlayerStore.instance.initApi() // the service will call the initApi on defining the streamerName Observer too, but it's better to initialize the API as soon as the user opens the activity.
136
+
137
+        // Post-UI Launch
138
+        if (PlayerStore.instance.isInitialized)
139
+        {
140
+            Log.d(tag, "skipped initialization")
141
+        } else {
142
+            // if the service is not started, start it in STOP mode.
143
+            // It's not a dummy action : with STOP mode, the player does not buffer audio (and does not use data connection without the user's consent).
144
+            // this is useful since the service must be started to register bluetooth devices buttons.
145
+            // (in case someone opens the app then pushes the PLAY button from their bluetooth device)
146
+            if(!PlayerStore.instance.isServiceStarted.value!!)
147
+                actionOnService(Actions.STOP)
148
+
149
+            // initialize some API data
150
+            PlayerStore.instance.initPicture(this)
151
+            PlayerStore.instance.streamerName.value = "" // initializing the streamer name will trigger an initApi from the observer in the Service.
152
+
153
+            // initialize the favorites
154
+            Requestor.instance.initFavorites()
155
+        }
156
+
157
+        if (!isTimerStarted)
158
+        {
159
+            // timers
160
+            // the clockTicker is used to update the UI. It's OK if it dies when the app loses focus.
161
+            // the timer is declared after access to PlayerStore so that PlayerStore is already initialized:
162
+            // Otherwise it makes the PlayerStore call its init{} block from a background thread --> crash
163
+            clockTicker.schedule(
164
+                Tick(),
165
+                0,
166
+                500
167
+            )
168
+            isTimerStarted = true
169
+        }
170
+
171
+        // initialize the UI
172
+        setTheme(R.style.AppTheme)
173
+        setContentView(R.layout.activity_main)
174
+        attachKeyboardListeners()
175
+
176
+        val toolbar : Toolbar = findViewById(R.id.toolbar)
177
+
178
+        // before setting up the bottom bar, we must declare the top bar that will be used by the bottom bar to display titles.
179
+        setSupportActionBar(toolbar)
180
+
181
+        if (savedInstanceState == null) {
182
+            setupBottomNavigationBar()
183
+        } // Else, need to wait for onRestoreInstanceState
184
+    }
185
+
186
+    override fun onDestroy() {
187
+        clockTicker.cancel()
188
+        super.onDestroy()
189
+    }
190
+
191
+    // ####################################
192
+    // ####### SERVICE PLAY / PAUSE #######
193
+    // ####################################
194
+
195
+
196
+    private fun actionOnService(a: Actions, v: Int = 0)
197
+    {
198
+            val i = Intent(this, RadioService::class.java)
199
+            i.putExtra("action", a.name)
200
+            i.putExtra("value", v)
201
+            Log.d(tag, "Sending intent ${a.name}")
202
+            startService(i)
203
+    }
204
+
205
+
206
+    // ####################################
207
+    // ###### SERVICE BINDER MANAGER ######
208
+    // ####################################
209
+
210
+    // NO BINDERS, only intents. That's the magic.
211
+    // Avoid code duplication, keep a single entry point to modify the service, and manage the service independently
212
+    // (no coupling between service and activity, as it should be ! Cause the notification makes changes too.)
213
+
214
+
215
+    /*
216
+    // ####################################
217
+    // ####### LOGGING TO FILE ############
218
+    // ####################################
219
+
220
+    // Checks if external storage is available for read and write
221
+    private val isExternalStorageWritable: Boolean
222
+        get() {
223
+            val state = Environment.getExternalStorageState()
224
+            return Environment.MEDIA_MOUNTED == state
225
+        }
226
+
227
+    // Checks if external storage is available to at least read
228
+    private val isExternalStorageReadable: Boolean
229
+        get() {
230
+            val state = Environment.getExternalStorageState()
231
+            return Environment.MEDIA_MOUNTED == state || Environment.MEDIA_MOUNTED_READ_ONLY == state
232
+        }
233
+
234
+
235
+    private fun logToFile()
236
+    {
237
+
238
+        // Logging
239
+        when {
240
+            isExternalStorageWritable -> {
241
+
242
+                val appDirectory = Environment.getExternalStorageDirectory()
243
+                // File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) + "/MyPersonalAppFolder")
244
+                val logDirectory = File("$appDirectory/log")
245
+                val logFile = File(logDirectory, "logcat" + System.currentTimeMillis() + ".txt")
246
+                Log.d(
247
+                    tag,
248
+                    "appDirectory : $appDirectory, logDirectory : $logDirectory, logFile : $logFile"
249
+                )
250
+
251
+                // create app folder
252
+                if (!appDirectory.exists()) {
253
+                    appDirectory.mkdir()
254
+                    Log.d(tag, "$appDirectory created")
255
+                }
256
+
257
+                // create log folder
258
+                if (!logDirectory.exists()) {
259
+                    logDirectory.mkdir()
260
+                    Log.d(tag, "$logDirectory created")
261
+                }
262
+
263
+                // clear the previous logcat and then write the new one to the file
264
+                try {
265
+                    Runtime.getRuntime().exec("logcat -c")
266
+                    Runtime.getRuntime().exec("logcat -v time -f $logFile *:E $tag:V ")
267
+                    Log.d(tag, "logcat started")
268
+                } catch (e: IOException) {
269
+                    e.printStackTrace()
270
+                }
271
+
272
+            }
273
+            isExternalStorageReadable -> {
274
+                // only readable
275
+            }
276
+            else -> {
277
+                // not accessible
278
+            }
279
+        }
280
+    }
281
+
282
+     */
283
+
284
+}

+ 245 - 0
app/src/main/java/io/r_a_d/radio2/NavigationExtensions.kt View File

@@ -0,0 +1,245 @@
1
+package io.r_a_d.radio2
2
+
3
+
4
+import android.content.Intent
5
+import android.util.SparseArray
6
+import androidx.core.util.forEach
7
+import androidx.core.util.set
8
+import androidx.fragment.app.FragmentManager
9
+import androidx.lifecycle.LiveData
10
+import androidx.lifecycle.MutableLiveData
11
+import androidx.navigation.NavController
12
+import androidx.navigation.fragment.NavHostFragment
13
+import com.google.android.material.bottomnavigation.BottomNavigationView
14
+
15
+/**
16
+ * YATTOZ' NOTE : this file has been scavenged from Android's "architecture-components-samples" repo.
17
+ * See it here:
18
+ * https://github.com/android/architecture-components-samples/blob/master/NavigationAdvancedSample/app/src/main/java/com/example/android/navigationadvancedsample/NavigationExtensions.kt
19
+ * it allows, among other things, to keep and restore fragments' state when they're swapped. Useful for IRC.
20
+ */
21
+
22
+
23
+/**
24
+ * Manages the various graphs needed for a [BottomNavigationView].
25
+ *
26
+ * This sample is a workaround until the Navigation Component supports multiple back stacks.
27
+ */
28
+fun BottomNavigationView.setupWithNavController(
29
+    navGraphIds: List<Int>,
30
+    fragmentManager: FragmentManager,
31
+    containerId: Int,
32
+    intent: Intent
33
+): LiveData<NavController> {
34
+
35
+    // Map of tags
36
+    val graphIdToTagMap = SparseArray<String>()
37
+    // Result. Mutable live data with the selected controlled
38
+    val selectedNavController = MutableLiveData<NavController>()
39
+
40
+    var firstFragmentGraphId = 0
41
+
42
+    // First create a NavHostFragment for each NavGraph ID
43
+    navGraphIds.forEachIndexed { index, navGraphId ->
44
+        val fragmentTag = getFragmentTag(index)
45
+
46
+        // Find or create the Navigation host fragment
47
+        val navHostFragment = obtainNavHostFragment(
48
+            fragmentManager,
49
+            fragmentTag,
50
+            navGraphId,
51
+            containerId
52
+        )
53
+
54
+        // Obtain its id
55
+        val graphId = navHostFragment.navController.graph.id
56
+
57
+        if (index == 0) {
58
+            firstFragmentGraphId = graphId
59
+        }
60
+
61
+        // Save to the map
62
+        graphIdToTagMap[graphId] = fragmentTag
63
+
64
+        // Attach or detach nav host fragment depending on whether it's the selected item.
65
+        if (this.selectedItemId == graphId) {
66
+            // Update livedata with the selected graph
67
+            selectedNavController.value = navHostFragment.navController
68
+            attachNavHostFragment(fragmentManager, navHostFragment, index == 0)
69
+        } else {
70
+            detachNavHostFragment(fragmentManager, navHostFragment)
71
+        }
72
+    }
73
+
74
+    // Now connect selecting an item with swapping Fragments
75
+    var selectedItemTag = graphIdToTagMap[this.selectedItemId]
76
+    val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId]
77
+    var isOnFirstFragment = selectedItemTag == firstFragmentTag
78
+
79
+    // When a navigation item is selected
80
+    setOnNavigationItemSelectedListener { item ->
81
+        // Don't do anything if the state is state has already been saved.
82
+        if (fragmentManager.isStateSaved) {
83
+            false
84
+        } else {
85
+            val newlySelectedItemTag = graphIdToTagMap[item.itemId]
86
+            if (selectedItemTag != newlySelectedItemTag) {
87
+                // Pop everything above the first fragment (the "fixed start destination")
88
+                fragmentManager.popBackStack(firstFragmentTag,
89
+                    FragmentManager.POP_BACK_STACK_INCLUSIVE)
90
+                val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
91
+                        as NavHostFragment
92
+
93
+                // Exclude the first fragment tag because it's always in the back stack.
94
+                if (firstFragmentTag != newlySelectedItemTag) {
95
+                    // Commit a transaction that cleans the back stack and adds the first fragment
96
+                    // to it, creating the fixed started destination.
97
+                    fragmentManager.beginTransaction()
98
+                    /* // YATTOZ' NOTE - disabling animations, it feels snappier and more in place.
99
+                        .setCustomAnimations(
100
+                            R.anim.nav_default_enter_anim,
101
+                            R.anim.nav_default_exit_anim,
102
+                            R.anim.nav_default_pop_enter_anim,
103
+                            R.anim.nav_default_pop_exit_anim)
104
+                     */
105
+                        .attach(selectedFragment)
106
+                        .setPrimaryNavigationFragment(selectedFragment)
107
+                        .apply {
108
+                            // Detach all other Fragments
109
+                            graphIdToTagMap.forEach { _, fragmentTagIter ->
110
+                                if (fragmentTagIter != newlySelectedItemTag) {
111
+                                    detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)
112
+                                }
113
+                            }
114
+                        }
115
+                        .addToBackStack(firstFragmentTag)
116
+                        .setReorderingAllowed(true)
117
+                        .commit()
118
+                }
119
+                selectedItemTag = newlySelectedItemTag
120
+                isOnFirstFragment = selectedItemTag == firstFragmentTag
121
+                selectedNavController.value = selectedFragment.navController
122
+                true
123
+            } else {
124
+                false
125
+            }
126
+        }
127
+    }
128
+
129
+    // Optional: on item reselected, pop back stack to the destination of the graph
130
+    setupItemReselected(graphIdToTagMap, fragmentManager)
131
+
132
+    // Handle deep link
133
+    setupDeepLinks(navGraphIds, fragmentManager, containerId, intent)
134
+
135
+    // Finally, ensure that we update our BottomNavigationView when the back stack changes
136
+    fragmentManager.addOnBackStackChangedListener {
137
+        if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) {
138
+            this.selectedItemId = firstFragmentGraphId
139
+        }
140
+
141
+        // Reset the graph if the currentDestination is not valid (happens when the back
142
+        // stack is popped after using the back button).
143
+        selectedNavController.value?.let { controller ->
144
+            if (controller.currentDestination == null) {
145
+                controller.navigate(controller.graph.id)
146
+            }
147
+        }
148
+    }
149
+    return selectedNavController
150
+}
151
+
152
+private fun BottomNavigationView.setupDeepLinks(
153
+    navGraphIds: List<Int>,
154
+    fragmentManager: FragmentManager,
155
+    containerId: Int,
156
+    intent: Intent
157
+) {
158
+    navGraphIds.forEachIndexed { index, navGraphId ->
159
+        val fragmentTag = getFragmentTag(index)
160
+
161
+        // Find or create the Navigation host fragment
162
+        val navHostFragment = obtainNavHostFragment(
163
+            fragmentManager,
164
+            fragmentTag,
165
+            navGraphId,
166
+            containerId
167
+        )
168
+        // Handle Intent
169
+        if (navHostFragment.navController.handleDeepLink(intent)
170
+            && selectedItemId != navHostFragment.navController.graph.id) {
171
+            this.selectedItemId = navHostFragment.navController.graph.id
172
+        }
173
+    }
174
+}
175
+
176
+private fun BottomNavigationView.setupItemReselected(
177
+    graphIdToTagMap: SparseArray<String>,
178
+    fragmentManager: FragmentManager
179
+) {
180
+    setOnNavigationItemReselectedListener { item ->
181
+        val newlySelectedItemTag = graphIdToTagMap[item.itemId]
182
+        val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
183
+                as NavHostFragment
184
+        val navController = selectedFragment.navController
185
+        // Pop the back stack to the start destination of the current navController graph
186
+        navController.popBackStack(
187
+            navController.graph.startDestination, false
188
+        )
189
+    }
190
+}
191
+
192
+private fun detachNavHostFragment(
193
+    fragmentManager: FragmentManager,
194
+    navHostFragment: NavHostFragment
195
+) {
196
+    fragmentManager.beginTransaction()
197
+        .detach(navHostFragment)
198
+        .commitNow()
199
+}
200
+
201
+private fun attachNavHostFragment(
202
+    fragmentManager: FragmentManager,
203
+    navHostFragment: NavHostFragment,
204
+    isPrimaryNavFragment: Boolean
205
+) {
206
+    fragmentManager.beginTransaction()
207
+        .attach(navHostFragment)
208
+        .apply {
209
+            if (isPrimaryNavFragment) {
210
+                setPrimaryNavigationFragment(navHostFragment)
211
+            }
212
+        }
213
+        .commitNow()
214
+
215
+}
216
+
217
+private fun obtainNavHostFragment(
218
+    fragmentManager: FragmentManager,
219
+    fragmentTag: String,
220
+    navGraphId: Int,
221
+    containerId: Int
222
+): NavHostFragment {
223
+    // If the Nav Host fragment exists, return it
224
+    val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
225
+    existingFragment?.let { return it }
226
+
227
+    // Otherwise, create it and return it.
228
+    val navHostFragment = NavHostFragment.create(navGraphId)
229
+    fragmentManager.beginTransaction()
230
+        .add(containerId, navHostFragment, fragmentTag)
231
+        .commitNow()
232
+    return navHostFragment
233
+}
234
+
235
+private fun FragmentManager.isOnBackStack(backStackName: String): Boolean {
236
+    val backStackCount = backStackEntryCount
237
+    for (index in 0 until backStackCount) {
238
+        if (getBackStackEntryAt(index).name == backStackName) {
239
+            return true
240
+        }
241
+    }
242
+    return false
243
+}
244
+
245
+private fun getFragmentTag(index: Int) = "bottomNavigation#$index"

+ 109 - 0
app/src/main/java/io/r_a_d/radio2/NowPlayingNotification.kt View File

@@ -0,0 +1,109 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.app.PendingIntent
4
+import android.content.Context
5
+import android.content.Intent
6
+import android.support.v4.media.session.MediaSessionCompat
7
+import android.support.v4.media.session.PlaybackStateCompat
8
+import androidx.core.app.NotificationCompat
9
+import io.r_a_d.radio2.playerstore.PlayerStore
10
+import io.r_a_d.radio2.BootBroadcastReceiver
11
+
12
+class NowPlayingNotification(
13
+    notificationChannelId: String,
14
+    notificationChannel : Int,
15
+    notificationId: Int,
16
+    notificationImportance: Int
17
+
18
+) : BaseNotification
19
+    (
20
+    notificationChannelId,
21
+    notificationChannel,
22
+    notificationId,
23
+    notificationImportance
24
+){
25
+
26
+    // ########################################
27
+    // ###### NOW PLAYING NOTIFICATION ########
28
+    // ########################################
29
+
30
+    lateinit var mediaStyle: androidx.media.app.NotificationCompat.DecoratedMediaCustomViewStyle
31
+
32
+    fun create(c: Context, m: MediaSessionCompat) {
33
+        super.create(c)
34
+
35
+        // got it right
36
+        val delIntent = Intent(c, RadioService::class.java)
37
+        delIntent.putExtra("action", Actions.KILL.name)
38
+        val deleteIntent = PendingIntent.getService(c, 0, delIntent, PendingIntent.FLAG_NO_CREATE)
39
+        builder.setDeleteIntent(deleteIntent)
40
+
41
+        mediaStyle = androidx.media.app.NotificationCompat.DecoratedMediaCustomViewStyle().also {
42
+            it.setMediaSession(m.sessionToken)
43
+            it.setShowActionsInCompactView(0) // index 0 = show actions 0 and 1 (show action #0 (play/pause))
44
+            it.setCancelButtonIntent(deleteIntent)
45
+        }
46
+        builder.setStyle(mediaStyle)
47
+        update(c)
48
+    }
49
+
50
+    fun update(c: Context, isUpdatingNotificationButton: Boolean = false, isRinging: Boolean = false) {
51
+
52
+        if (isUpdatingNotificationButton)
53
+            builder.mActions.clear()
54
+
55
+        // Title : Title of notification (usu. songArtist is first)
56
+        // Text : Text of the notification (usu. songTitle is second)
57
+        builder.setContentTitle(PlayerStore.instance.currentSong.artist.value)
58
+        builder.setContentText(PlayerStore.instance.currentSong.title.value)
59
+        // As subText, we show when the player is stopped. This is a friendly reminder that the metadata won't get updated.
60
+        // Maybe later we could replace it by a nice progressBar? Would it be interesting to have one here? I don't know.
61
+        if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED) {
62
+            builder.setSubText("Stopped")
63
+            builder.setShowWhen(false)
64
+        }
65
+        else {
66
+            builder.setSubText(null)
67
+            builder.setShowWhen(true)
68
+        }
69
+
70
+        if (builder.mActions.isEmpty()) {
71
+            val intent = Intent(c, RadioService::class.java)
72
+            val playPauseAction: NotificationCompat.Action
73
+
74
+            playPauseAction = if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING) {
75
+                intent.putExtra("action", Actions.PAUSE.name)
76
+                val pendingButtonIntent = PendingIntent.getService(c, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)
77
+                NotificationCompat.Action.Builder(R.drawable.ic_pause, "Pause", pendingButtonIntent).build()
78
+            } else {
79
+                intent.putExtra("action", Actions.PLAY.name)
80
+                val pendingButtonIntent = PendingIntent.getService(c, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)
81
+                NotificationCompat.Action.Builder(R.drawable.ic_play,"Play", pendingButtonIntent).build()
82
+            }
83
+            builder.addAction(playPauseAction)
84
+            val intent2 = Intent(c, RadioService::class.java)
85
+            intent2.putExtra("action", Actions.KILL.name)
86
+            val pendingButtonIntent = PendingIntent.getService(c, 2, intent2, PendingIntent.FLAG_UPDATE_CURRENT)
87
+            val stopAction = NotificationCompat.Action.Builder(R.drawable.ic_stop,"Stop", pendingButtonIntent).build()
88
+            builder.addAction(stopAction)
89
+
90
+            if (isRinging) {
91
+                val snoozeString = preferenceStore.getString("snoozeDuration", "10") ?: "10"
92
+                val snoozeMinutes = if (snoozeString == c.getString(R.string.disable)) 0  else Integer.parseInt(snoozeString)
93
+
94
+                val snoozeIntent = Intent(c, RadioService::class.java)
95
+                snoozeIntent.putExtra("action", Actions.SNOOZE.name)
96
+                val pendingSnoozeIntent = PendingIntent.getService(c, 5, snoozeIntent, PendingIntent.FLAG_UPDATE_CURRENT)
97
+                val snoozeAction = NotificationCompat.Action.Builder(R.drawable.ic_alarm, "Snooze ($snoozeMinutes min.)", pendingSnoozeIntent ).build()
98
+                if (snoozeMinutes > 0)
99
+                    builder.addAction(snoozeAction)
100
+                builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
101
+            } else {
102
+                builder.setStyle(mediaStyle)
103
+            }
104
+        }
105
+        builder.setLargeIcon(PlayerStore.instance.streamerPicture.value)
106
+
107
+        super.show()
108
+    }
109
+}

+ 42 - 0
app/src/main/java/io/r_a_d/radio2/ParametersActivity.kt View File

@@ -0,0 +1,42 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.os.Bundle
4
+import io.r_a_d.radio2.preferences.*
5
+
6
+
7
+class ParametersActivity : BaseActivity() {
8
+
9
+    override fun onCreate(savedInstanceState: Bundle?) {
10
+        super.onCreate(savedInstanceState)
11
+
12
+        // UI Launch
13
+        setTheme(R.style.AppTheme_Parameters)
14
+        setContentView(R.layout.activity_parameters)
15
+
16
+        // my_child_toolbar is defined in the layout file
17
+        setSupportActionBar(findViewById(R.id.toolbar))
18
+
19
+        // Get a support ActionBar corresponding to this toolbar and enable the Up button
20
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
21
+
22
+        val extra = if (savedInstanceState == null) {
23
+            intent.extras?.getString("action")
24
+        } else {
25
+            savedInstanceState.getSerializable("action") as String
26
+        }
27
+
28
+        val fragmentToLoad = when(extra) {
29
+            ActionOpenParam.ALARM.name -> AlarmFragment()
30
+            ActionOpenParam.SLEEP.name -> SleepFragment()
31
+            ActionOpenParam.CUSTOMIZE.name -> CustomizeFragment()
32
+            ActionOpenParam.STREAMER_NOTIFICATION_SERVICE.name -> StreamerNotifServiceFragment()
33
+            else -> MainPreferenceFragment()
34
+        }
35
+
36
+
37
+        supportFragmentManager
38
+            .beginTransaction()
39
+            .replace(R.id.parameters_host_container, fragmentToLoad)
40
+            .commit()
41
+    }
42
+}

+ 664 - 0
app/src/main/java/io/r_a_d/radio2/RadioService.kt View File

@@ -0,0 +1,664 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.app.Service
4
+import android.content.BroadcastReceiver
5
+import android.content.Context
6
+import android.content.Intent
7
+import android.content.IntentFilter
8
+import android.graphics.Bitmap
9
+import android.support.v4.media.MediaBrowserCompat
10
+import android.util.Log
11
+import androidx.media.MediaBrowserServiceCompat
12
+import android.media.AudioManager
13
+import android.os.*
14
+import android.support.v4.media.session.MediaSessionCompat
15
+import android.support.v4.media.session.PlaybackStateCompat
16
+import androidx.media.session.MediaButtonReceiver
17
+import com.google.android.exoplayer2.source.ProgressiveMediaSource
18
+import com.google.android.exoplayer2.util.Util.getUserAgent
19
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
20
+
21
+import android.net.Uri
22
+import android.support.v4.media.MediaMetadataCompat
23
+import android.telephony.PhoneStateListener
24
+import android.telephony.TelephonyManager
25
+import android.view.KeyEvent
26
+import androidx.core.app.NotificationCompat
27
+import androidx.core.content.edit
28
+import androidx.lifecycle.Observer
29
+import androidx.media.AudioAttributesCompat
30
+import androidx.media.AudioFocusRequestCompat
31
+import androidx.media.AudioManagerCompat
32
+import androidx.preference.PreferenceManager
33
+import com.google.android.exoplayer2.*
34
+import com.google.android.exoplayer2.metadata.icy.*
35
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
36
+import io.r_a_d.radio2.alarm.RadioAlarm
37
+import io.r_a_d.radio2.alarm.RadioSleeper
38
+import io.r_a_d.radio2.playerstore.PlayerStore
39
+import java.util.*
40
+import kotlin.math.exp
41
+import kotlin.math.ln
42
+import kotlin.system.exitProcess
43
+
44
+
45
+class RadioService : MediaBrowserServiceCompat() {
46
+
47
+    private val radioTag = "======RadioService====="
48
+    private lateinit var nowPlayingNotification: NowPlayingNotification
49
+    private val radioServiceId = 1
50
+    private var numberOfSongs = 0
51
+    private val apiTicker: Timer = Timer()
52
+    private var isAlarmStopped: Boolean = false
53
+
54
+    // Define the broadcast receiver to handle any broadcasts
55
+    private val receiver = object : BroadcastReceiver() {
56
+        override fun onReceive(context: Context, intent: Intent) {
57
+            val action = intent.action
58
+            if (action != null && action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
59
+                val i = Intent(context, RadioService::class.java)
60
+                i.putExtra("action", Actions.STOP.name)
61
+                context.startService(i)
62
+            }
63
+            if (action != null && action == Intent.ACTION_HEADSET_PLUG)
64
+            {
65
+                var headsetPluggedIn = false
66
+
67
+                // In the Intent state there's the value whether the headphones are plugged or not.
68
+                // This *should* work in any case...
69
+                when (intent.getIntExtra("state", -1)) {
70
+                0 -> {
71
+                    Log.d(tag, radioTag + "Headset is unplugged")
72
+                }
73
+                1 -> {
74
+                    Log.d(tag, radioTag + "Headset is plugged")
75
+                    headsetPluggedIn = true
76
+                }
77
+                else -> {
78
+                    Log.d(tag, radioTag + "I have no idea what the headset state is")
79
+                }
80
+                }
81
+                /*
82
+                val am = getSystemService(AUDIO_SERVICE) as AudioManager
83
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
84
+                {
85
+                    val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
86
+                    for (d in devices)
87
+                    {
88
+                        if (d.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || d.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES)
89
+                            headsetPluggedIn = true
90
+                    }
91
+                }
92
+                else
93
+                {
94
+                    Log.d(tag, radioTag + "Can't get state?")
95
+                }
96
+
97
+                 */
98
+                if((mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_STOPPED
99
+                    || mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_PAUSED)
100
+                    && headsetPluggedIn)
101
+                    beginPlaying()
102
+            }
103
+        }
104
+    }
105
+
106
+    // ##################################################
107
+    // ################### OBSERVERS ####################
108
+    // ##################################################
109
+
110
+    private val titleObserver: Observer<String> = Observer {
111
+        if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING)
112
+        {
113
+            Log.d(tag, radioTag + "SONG CHANGED AND PLAYING")
114
+            // we activate latency compensation only if it's been at least 2 songs...
115
+            when {
116
+                PlayerStore.instance.isStreamDown -> {
117
+                    // if we reach here, it means that the observer has been called by a new song and that the stream was down previously.
118
+                    // so the stream is now back to normal.
119
+                    PlayerStore.instance.isStreamDown = false
120
+                    PlayerStore.instance.initApi()
121
+                }
122
+                PlayerStore.instance.currentSong.title.value == getString(R.string.ed) -> {
123
+                    PlayerStore.instance.isStreamDown = true
124
+                }
125
+                else -> {
126
+                    PlayerStore.instance.fetchApi(numberOfSongs >= 2)
127
+                }
128
+            }
129
+        }
130
+        nowPlayingNotification.update(this)
131
+    }
132
+
133
+    private val volumeObserver: Observer<Int> = Observer {
134
+        setVolume(it)
135
+    }
136
+
137
+    private val isMutedObserver: Observer<Boolean> = Observer {
138
+        setVolume(if (it) null else -1)
139
+    }
140
+
141
+    private val isPlayingObserver: Observer<Boolean> = Observer {
142
+        if (it)
143
+            beginPlaying()
144
+        else
145
+            stopPlaying()
146
+    }
147
+
148
+    private val startTimeObserver = Observer<Long> {
149
+        // We're listening to startTime to determine if we have to update Queue and Lp.
150
+        // this is because startTime is set by the API and never by the ICY, so both cases are covered (playing and stopped)
151
+        // should be OK even when a new streamer comes in.
152
+        if (it != PlayerStore.instance.currentSongBackup.startTime.value) // we have a new song
153
+        {
154
+            PlayerStore.instance.updateLp()
155
+            PlayerStore.instance.updateQueue()
156
+        }
157
+    }
158
+
159
+    private val streamerObserver = Observer<String> {
160
+        PlayerStore.instance.initApi()
161
+        nowPlayingNotification.update(this) // should update the streamer icon
162
+    }
163
+
164
+    private val streamerPictureObserver = Observer<Bitmap> {
165
+        nowPlayingNotification.update(this)
166
+    }
167
+
168
+    // ##################################################
169
+    // ############## LIFECYCLE CALLBACKS ###############
170
+    // ##################################################
171
+
172
+    override fun onLoadChildren(
173
+        parentId: String,
174
+        result: Result<MutableList<MediaBrowserCompat.MediaItem>>
175
+    ) {
176
+        result.sendResult(null)
177
+    }
178
+
179
+    override fun onGetRoot(
180
+        clientPackageName: String,
181
+        clientUid: Int,
182
+        rootHints: Bundle?
183
+    ): BrowserRoot? {
184
+        // Clients can connect, but you can't browse internet radio
185
+        // so onLoadChildren returns nothing. This disables the ability to browse for content.
186
+        return BrowserRoot(getString(R.string.MEDIA_ROOT_ID), null)
187
+    }
188
+
189
+    override fun onCreate() {
190
+        super.onCreate()
191
+
192
+        preferenceStore = PreferenceManager.getDefaultSharedPreferences(this)
193
+
194
+        // Define managers
195
+        telephonyManager = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
196
+        telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
197
+        audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
198
+
199
+        //define the audioFocusRequest
200
+        val audioFocusRequestBuilder = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
201
+        audioFocusRequestBuilder.setOnAudioFocusChangeListener(focusChangeListener)
202
+        val audioAttributes = AudioAttributesCompat.Builder()
203
+        audioAttributes.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
204
+        audioAttributes.setUsage(AudioAttributesCompat.USAGE_MEDIA)
205
+        audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build())
206
+        audioFocusRequest = audioFocusRequestBuilder.build()
207
+
208
+        // This stuff is for the broadcast receiver
209
+        val filter = IntentFilter()
210
+        filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
211
+        filter.addAction(Intent.ACTION_HEADSET_PLUG)
212
+        registerReceiver(receiver, filter)
213
+
214
+        // setup media player
215
+        setupMediaPlayer()
216
+        createMediaSession()
217
+
218
+        nowPlayingNotification = NowPlayingNotification(
219
+            notificationChannelId = this.getString(R.string.nowPlayingChannelId),
220
+            notificationChannel = R.string.nowPlayingNotificationChannel,
221
+            notificationId = 1,
222
+            notificationImportance = NotificationCompat.PRIORITY_LOW
223
+        )
224
+        nowPlayingNotification.create(this, mediaSession)
225
+
226
+
227
+        PlayerStore.instance.streamerName.observeForever(streamerObserver)
228
+        PlayerStore.instance.currentSong.title.observeForever(titleObserver)
229
+        PlayerStore.instance.currentSong.startTime.observeForever(startTimeObserver)
230
+        PlayerStore.instance.volume.observeForever(volumeObserver)
231
+        PlayerStore.instance.isPlaying.observeForever(isPlayingObserver)
232
+        PlayerStore.instance.isMuted.observeForever(isMutedObserver)
233
+        PlayerStore.instance.streamerPicture.observeForever(streamerPictureObserver)
234
+
235
+        startForeground(radioServiceId, nowPlayingNotification.notification)
236
+
237
+        // start ticker for when the player is stopped
238
+        val periodString = PreferenceManager.getDefaultSharedPreferences(this).getString("fetchPeriod", "10") ?: "10"
239
+        val period: Long = Integer.parseInt(periodString).toLong()
240
+        if (period > 0)
241
+            apiTicker.schedule(ApiFetchTick(), 0, period * 1000)
242
+
243
+        PlayerStore.instance.isServiceStarted.value = true
244
+        Log.d(tag, radioTag + "created")
245
+    }
246
+
247
+    private val handler = Handler()
248
+    class LowerVolumeRunnable : Runnable {
249
+        override fun run() {
250
+            PlayerStore.instance.volume.postValue(
251
+                (PlayerStore.instance.volume.value!!.toFloat() * (9f / 10f)).toInt()
252
+            ) // the setVolume is called by the volumeObserver in RadioService (on main thread for ExoPlayer!)
253
+        }
254
+    }
255
+    private val lowerVolumeRunnable = LowerVolumeRunnable()
256
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
257
+        if (intent?.getStringExtra("action") == null)
258
+            return super.onStartCommand(intent, flags, startId)
259
+
260
+        if (MediaButtonReceiver.handleIntent(mediaSession, intent) != null)
261
+            return super.onStartCommand(intent, flags, startId)
262
+
263
+        when (intent.getStringExtra("action")) {
264
+            Actions.PLAY.name -> beginPlaying()
265
+            Actions.STOP.name -> { setVolume(PlayerStore.instance.volume.value); stopPlaying() } // setVolume is here to reset the volume to the user's preference when the alarm (that sets volume to 100) is dismissed
266
+            Actions.PAUSE.name -> { setVolume(PlayerStore.instance.volume.value); pausePlaying() }
267
+            Actions.VOLUME.name -> setVolume(intent.getIntExtra("value", 100))
268
+            Actions.KILL.name -> {stopForeground(true); stopSelf(); return Service.START_NOT_STICKY}
269
+            Actions.NOTIFY.name -> nowPlayingNotification.update(this)
270
+            Actions.PLAY_OR_FALLBACK.name -> beginPlayingOrFallback()
271
+            Actions.FADE_OUT.name -> {
272
+                for (i in 1 until 30) // we schedule 30 "LowerVolumeRunnable" every 2 seconds (i * 2)
273
+                {
274
+                    // I couldn't find how to send multiple times the same PendingIntent using AlarmManager, so I relied on Handler instead.
275
+                    // I think there's no guarantee of exact time with the Handler, especially when the device is in deep sleep,
276
+                    // But when I force-set the Deep Sleep mode with ADB, it worked fine, so I'll leave it as this.
277
+                    // BUT! SOMETIMES IT DIDN'T WORK AND I DON'T KNOW WHY.
278
+                    // I hope that moving the handler in the RadioService would solve the issue and trigger it correctly.
279
+                    handler.postDelayed(lowerVolumeRunnable, (i * 2 * 1000).toLong())
280
+                }
281
+            }
282
+            Actions.CANCEL_FADE_OUT.name -> { handler.removeCallbacks(lowerVolumeRunnable) }
283
+            Actions.SNOOZE.name -> { RadioAlarm.instance.snooze(this) }
284
+        }
285
+        Log.d(tag, radioTag + "intent received : " + intent.getStringExtra("action"))
286
+        super.onStartCommand(intent, flags, startId)
287
+        // The service must be re-created if it is destroyed by the system. This allows the user to keep actions like Bluetooth and headphones plug available.
288
+        return START_STICKY
289
+    }
290
+
291
+    override fun onTaskRemoved(rootIntent: Intent) {
292
+        if (mediaSession.controller.playbackState.state != PlaybackStateCompat.STATE_PLAYING) {
293
+            nowPlayingNotification.clear()
294
+            stopSelf()
295
+        }
296
+        super.onTaskRemoved(rootIntent)
297
+        Log.d(tag, radioTag + "task removed")
298
+    }
299
+
300
+    override fun onDestroy() {
301
+        super.onDestroy()
302
+        player.stop()
303
+        player.release()
304
+        unregisterReceiver(receiver)
305
+        PlayerStore.instance.currentSong.title.removeObserver(titleObserver)
306
+        PlayerStore.instance.currentSong.startTime.removeObserver(startTimeObserver)
307
+        PlayerStore.instance.volume.removeObserver(volumeObserver)
308
+        PlayerStore.instance.isPlaying.removeObserver(isPlayingObserver)
309
+        PlayerStore.instance.isMuted.removeObserver(isMutedObserver)
310
+        PlayerStore.instance.streamerPicture.removeObserver(streamerPictureObserver)
311
+
312
+
313
+        mediaSession.isActive = false
314
+        mediaSession.setMediaButtonReceiver(null)
315
+
316
+        mediaSession.release()
317
+
318
+        PlayerStore.instance.isServiceStarted.value = false
319
+        PlayerStore.instance.isInitialized = false
320
+
321
+        PreferenceManager.getDefaultSharedPreferences(this).edit {
322
+            this.putBoolean("isSleeping", false)
323
+            this.commit()
324
+        }
325
+        RadioSleeper.instance.cancelSleep(this)
326
+
327
+        apiTicker.cancel() // stops the timer.
328
+        Log.d(tag, radioTag + "destroyed")
329
+        // if the service is destroyed, the application had become useless.
330
+        exitProcess(0)
331
+    }
332
+
333
+    // ########################################
334
+    // ######## AUDIO FOCUS MANAGEMENT ########
335
+    //#########################################
336
+
337
+    // Define the managers
338
+    private var telephonyManager: TelephonyManager? = null
339
+    private lateinit var audioManager: AudioManager
340
+    private lateinit var audioFocusRequest: AudioFocusRequestCompat
341
+
342
+    private val phoneStateListener = object : PhoneStateListener() {
343
+        override fun onCallStateChanged(state: Int, incomingNumber: String) {
344
+            super.onCallStateChanged(state, incomingNumber)
345
+
346
+            if (state != TelephonyManager.CALL_STATE_IDLE) {
347
+                setVolume(0)
348
+            } else {
349
+                setVolume(PlayerStore.instance.volume.value!!)
350
+            }
351
+        }
352
+    }
353
+
354
+    // Define the listener that will control what happens when focus is changed
355
+    private val focusChangeListener =
356
+        AudioManager.OnAudioFocusChangeListener { focusChange ->
357
+            when (focusChange) {
358
+                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> setVolume((0.20f * PlayerStore.instance.volume.value!!).toInt()) //20% of current volume.
359
+                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> setVolume(0)
360
+                AudioManager.AUDIOFOCUS_LOSS -> stopPlaying()
361
+                AudioManager.AUDIOFOCUS_GAIN -> setVolume(PlayerStore.instance.volume.value!!)
362
+                else -> {}
363
+            }
364
+        }
365
+
366
+    // ########################################
367
+    // ######## MEDIA PLAYER / SESSION ########
368
+    // ########################################
369
+
370
+    private lateinit var mediaSession : MediaSessionCompat
371
+    private lateinit var playbackStateBuilder: PlaybackStateCompat.Builder
372
+    private lateinit var metadataBuilder: MediaMetadataCompat.Builder
373
+    private lateinit var player: SimpleExoPlayer
374
+    private lateinit var radioMediaSource: ProgressiveMediaSource
375
+    private lateinit var fallbackMediaSource: ProgressiveMediaSource
376
+
377
+    private fun setupMediaPlayer(){
378
+
379
+        val minBufferMillis = 15 * 1000 // Default value
380
+        val maxBufferMillis = 50 * 1000 // Default value
381
+        val bufferForPlayback = 4 * 1000 // Default is 2.5s.
382
+        // Increasing it makes it more robust to short connection loss, at the expense of latency when we press Play. 4s seems reasonable to me.
383
+        val bufferForPlaybackAfterRebuffer = 7 * 1000 // Default is 5s.
384
+
385
+        val loadControl = DefaultLoadControl.Builder().apply {
386
+            setBufferDurationsMs(minBufferMillis, maxBufferMillis, bufferForPlayback, bufferForPlaybackAfterRebuffer)
387
+        }.createDefaultLoadControl()
388
+
389
+        player = ExoPlayerFactory.newSimpleInstance(this, DefaultTrackSelector(), loadControl)
390
+        player.addMetadataOutput {
391
+            for (i in 0 until it.length()) {
392
+                val entry  = it.get(i)
393
+                if (entry is IcyHeaders) {
394
+                    Log.d(tag, radioTag + "onMetadata: IcyHeaders $entry")
395
+                }
396
+                if (entry is IcyInfo) {
397
+                    Log.d(tag, radioTag + "onMetadata: Title ----> ${entry.title}")
398
+                    // Note : Kotlin supports UTF-8 by default.
399
+                    numberOfSongs++
400
+                    val data = entry.title!!
401
+                    PlayerStore.instance.currentSong.setTitleArtist(data)
402
+                }
403
+                val d : Long = ((PlayerStore.instance.currentSong.stopTime.value?.minus(PlayerStore.instance.currentSong.startTime.value!!) ?: 0) / 1000)
404
+                val duration = if (d > 0) d - (PlayerStore.instance.latencyCompensator) else 0
405
+                metadataBuilder.putString(
406
+                    MediaMetadataCompat.METADATA_KEY_TITLE,
407
+                    PlayerStore.instance.currentSong.title.value
408
+                )
409
+                    .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, PlayerStore.instance.currentSong.artist.value)
410
+                    .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
411
+
412
+                mediaSession.setMetadata(metadataBuilder.build())
413
+
414
+                val intent = Intent("com.android.music.metachanged")
415
+                intent.putExtra("artist", PlayerStore.instance.currentSong.artist.value)
416
+                intent.putExtra("track", PlayerStore.instance.currentSong.title.value)
417
+                intent.putExtra("duration", duration)
418
+                intent.putExtra("position", 0)
419
+                sendBroadcast(intent)
420
+            }
421
+        }
422
+        // this listener allows to reset numberOfSongs if the connection is lost.
423
+        player.addListener(exoPlayerEventListener)
424
+
425
+        // Produces DataSource instances through which media data is loaded.
426
+        val dataSourceFactory = DefaultDataSourceFactory(
427
+            this,
428
+            getUserAgent(this, getString(R.string.app_name))
429
+        )
430
+        // This is the MediaSource representing the media to be played.
431
+        radioMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
432
+            .createMediaSource(Uri.parse(getString(R.string.STREAM_URL_RADIO)))
433
+
434
+        fallbackMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
435
+            .createMediaSource(Uri.parse("file:///android_asset/the_stream_is_down.mp3"))
436
+    }
437
+
438
+    private fun createMediaSession() {
439
+        mediaSession = MediaSessionCompat(this, "RadioMediaSession")
440
+        // Deprecated flags
441
+        // mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS and MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS)
442
+        mediaSession.isActive = true
443
+        mediaSession.setCallback(mediaSessionCallback)
444
+        playbackStateBuilder = PlaybackStateCompat.Builder()
445
+        playbackStateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE)
446
+            .setState(PlaybackStateCompat.STATE_STOPPED, 0, 1.0f, SystemClock.elapsedRealtime())
447
+
448
+        metadataBuilder = MediaMetadataCompat.Builder()
449
+        mediaSession.setPlaybackState(playbackStateBuilder.build())
450
+    }
451
+
452
+    // ########################################
453
+    // ######### SERVICE START/STOP ###########
454
+    // ########################################
455
+
456
+    // this function is playing the stream if available, or a default sound if there's a problem.
457
+    private fun beginPlayingOrFallback()
458
+    {
459
+        PlayerStore.instance.volume.value = 100 // we set the max volume for exoPlayer to be sure it rings correctly.
460
+        beginPlaying(isRinging = true, isFallback = false)
461
+        val wait: (Any?) -> Any = {
462
+            /*
463
+            Here we lower the isAlarmStopped flag and we wait for 17s. (seems like 12 could be a bit too short since I increased the buffer!!)
464
+            If the player stops the alarm (by calling an intent), the isAlarmStopped flag will be raised.
465
+             */
466
+            isAlarmStopped = false // reset the flag
467
+            var i = 0
468
+            while (i < 17)
469
+            {
470
+                Thread.sleep(1000)
471
+                i++
472
+            }
473
+        }
474
+        val post: (Any?) -> Unit = {
475
+            // we verify : if the player is not playing, and if the user didn't stop it, it means that there's a network issue.
476
+            // So we use the fallback sound to wake up the user!!
477
+            // (note: player.isPlaying is only accessible on main thread, so we can't check in the wait() lambda)
478
+            if (!player.isPlaying && !isAlarmStopped)
479
+                beginPlaying(isRinging = true, isFallback = true)
480
+        }
481
+        Async(wait, post)
482
+    }
483
+
484
+    fun beginPlaying(isRinging: Boolean = false, isFallback: Boolean = false)
485
+    {
486
+        //define the audioFocusRequest
487
+        val audioFocusRequestBuilder = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
488
+        audioFocusRequestBuilder.setOnAudioFocusChangeListener(focusChangeListener)
489
+        val audioAttributes = AudioAttributesCompat.Builder()
490
+        audioAttributes.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
491
+        if (isRinging)
492
+        {
493
+            audioAttributes.setUsage(AudioAttributesCompat.USAGE_ALARM)
494
+            audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build())
495
+            audioFocusRequest = audioFocusRequestBuilder.build()
496
+            player.audioAttributes = com.google.android.exoplayer2.audio.AudioAttributes
497
+                .Builder()
498
+                .setContentType(C.CONTENT_TYPE_MUSIC)
499
+                .setUsage(C.USAGE_ALARM)
500
+                .build()
501
+        } else {
502
+            audioAttributes.setUsage(AudioAttributesCompat.USAGE_MEDIA)
503
+            audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build())
504
+            audioFocusRequest = audioFocusRequestBuilder.build()
505
+            player.audioAttributes = com.google.android.exoplayer2.audio.AudioAttributes
506
+                .Builder()
507
+                .setContentType(C.CONTENT_TYPE_MUSIC)
508
+                .setUsage(C.USAGE_MEDIA)
509
+                .build()
510
+        }
511
+        // the old requestAudioFocus is deprecated on API26+. Using AudioManagerCompat library for consistent code across versions
512
+        val result = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)
513
+        if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
514
+            return
515
+        }
516
+
517
+        if (mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_PLAYING && !isRinging)
518
+            return // nothing to do here
519
+        PlayerStore.instance.playbackState.value = PlaybackStateCompat.STATE_PLAYING
520
+
521
+        // Reinitialize media player. Otherwise the playback doesn't resume when beginPlaying. Dunno why.
522
+        // Prepare the player with the source.
523
+        if (isFallback)
524
+        {
525
+            player.prepare(fallbackMediaSource)
526
+            player.repeatMode = ExoPlayer.REPEAT_MODE_ALL
527
+        }
528
+        else {
529
+            player.prepare(radioMediaSource)
530
+            player.repeatMode = ExoPlayer.REPEAT_MODE_OFF
531
+        }
532
+
533
+        // START PLAYBACK, LET'S ROCK
534
+        player.playWhenReady = true
535
+        nowPlayingNotification.update(this, isUpdatingNotificationButton =  true, isRinging = isRinging)
536
+
537
+        playbackStateBuilder.setState(
538
+            PlaybackStateCompat.STATE_PLAYING,
539
+            0,
540
+            1.0f,
541
+            SystemClock.elapsedRealtime()
542
+        )
543
+        mediaSession.setPlaybackState(playbackStateBuilder.build())
544
+        Log.d(tag, radioTag + "begin playing")
545
+    }
546
+
547
+    private fun pausePlaying()
548
+    {
549
+        stopPlaying()
550
+    }
551
+
552
+    // stop playing but keep the notification.
553
+    fun stopPlaying()
554
+    {
555
+        isAlarmStopped = true
556
+        if (mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_STOPPED)
557
+            return // nothing to do here
558
+        PlayerStore.instance.playbackState.value = PlaybackStateCompat.STATE_STOPPED
559
+
560
+        // STOP THE PLAYBACK
561
+        player.stop()
562
+
563
+        nowPlayingNotification.update(this, true)
564
+        playbackStateBuilder.setState(
565
+            PlaybackStateCompat.STATE_STOPPED,
566
+            0,
567
+            1.0f,
568
+            SystemClock.elapsedRealtime()
569
+        )
570
+        Log.d(tag, radioTag + "stopped")
571
+
572
+        mediaSession.setPlaybackState(playbackStateBuilder.build())
573
+    }
574
+
575
+    fun setVolume(vol: Int?) {
576
+        var v = vol
577
+        when(v)
578
+        {
579
+            null -> { player.volume = 0f ; return } // null means "mute"
580
+            -1 -> v = PlayerStore.instance.volume.value // -1 means "restore previous volume"
581
+        }
582
+
583
+        // re-shaped volume setter with a logarithmic (ln) function.
584
+        // I think it sounds more natural this way. Adjust coefficient to change the function shape.
585
+        // visualize it on any graphic calculator if you're unsure.
586
+        val c : Float = 2.toFloat()
587
+        val x : Float = v!!.toFloat()/100
588
+        player.volume = -(1/c)* ln(1-(1- exp(-c))*x)
589
+    }
590
+
591
+    private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
592
+        override fun onPlay() {
593
+            beginPlaying()
594
+        }
595
+
596
+        override fun onPause() {
597
+            pausePlaying()
598
+        }
599
+
600
+        override fun onStop() {
601
+            stopPlaying()
602
+        }
603
+
604
+        override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
605
+            // explicit handling of Media Buttons (for example bluetooth commands)
606
+            // The hardware key on a corded headphones are handled in the MainActivity (for <API21)
607
+            if (PlayerStore.instance.isServiceStarted.value!!) {
608
+                val keyEvent =
609
+                    mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
610
+                if (keyEvent == null || ((keyEvent.action) != KeyEvent.ACTION_DOWN)) {
611
+                    return false
612
+                }
613
+
614
+                when (keyEvent.keyCode) {
615
+                    KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> {
616
+                        //// Is this some kind of debouncing ? I'm not sure.
617
+                        //if (keyEvent.repeatCount > 0) {
618
+                        //    return false
619
+                        //} else {
620
+                        if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING)
621
+                            pausePlaying()
622
+                        else
623
+                            beginPlaying()
624
+                        //}
625
+                        return true
626
+                    }
627
+                    KeyEvent.KEYCODE_MEDIA_STOP -> stopPlaying()
628
+                    KeyEvent.KEYCODE_MEDIA_PAUSE -> pausePlaying()
629
+                    KeyEvent.KEYCODE_MEDIA_PLAY -> beginPlaying()
630
+                    else -> return false // these actions are the only ones we acknowledge.
631
+                }
632
+
633
+            }
634
+            return false
635
+        }
636
+    }
637
+
638
+    private val exoPlayerEventListener = object : Player.EventListener {
639
+        override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
640
+            super.onPlayerStateChanged(playWhenReady, playbackState)
641
+            numberOfSongs = 0
642
+            var state = ""
643
+            when(playbackState)
644
+            {
645
+                Player.STATE_BUFFERING -> state = "Player.STATE_BUFFERING"
646
+                Player.STATE_IDLE -> {
647
+                    state = "Player.STATE_IDLE"
648
+                    // inform the PlayerStore that the playback has stopped. This enables the ticker, triggers API fetch, and updates UI in no-network state.
649
+                    if (PlayerStore.instance.playbackState.value != PlaybackStateCompat.STATE_STOPPED)
650
+                    {
651
+                        PlayerStore.instance.playbackState.postValue(PlaybackStateCompat.STATE_STOPPED)
652
+                        PlayerStore.instance.isPlaying.postValue(false)
653
+                    }
654
+                }
655
+                Player.STATE_ENDED -> state = "Player.STATE_ENDED"
656
+                Player.STATE_READY -> state = "Player.STATE_READY"
657
+            }
658
+            Log.d(tag, radioTag + "Player changed state: ${state}. numberOfSongs reset.")
659
+        }
660
+    }
661
+
662
+
663
+
664
+}

+ 21 - 0
app/src/main/java/io/r_a_d/radio2/Tickers.kt View File

@@ -0,0 +1,21 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.support.v4.media.session.PlaybackStateCompat
4
+import io.r_a_d.radio2.alarm.RadioSleeper
5
+import io.r_a_d.radio2.playerstore.PlayerStore
6
+import java.util.*
7
+
8
+class Tick  : TimerTask() {
9
+    override fun run() {
10
+        PlayerStore.instance.currentTime.postValue(PlayerStore.instance.currentTime.value!! + 500)
11
+    }
12
+}
13
+
14
+class ApiFetchTick  : TimerTask() {
15
+    override fun run() {
16
+        if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED)
17
+        {
18
+            PlayerStore.instance.fetchApi()
19
+        }
20
+    }
21
+}

+ 14 - 0
app/src/main/java/io/r_a_d/radio2/Values.kt View File

@@ -0,0 +1,14 @@
1
+package io.r_a_d.radio2
2
+
3
+import android.content.SharedPreferences
4
+import android.content.res.ColorStateList
5
+
6
+const val tag = "io.r_a_d.radio2"
7
+const val noConnectionValue = "No connection"
8
+var colorBlue: Int = 0
9
+var colorWhited: Int = 0
10
+var colorGreenList: ColorStateList? = ColorStateList.valueOf(0)
11
+var colorRedList: ColorStateList? = ColorStateList.valueOf(0)
12
+var colorGreenListCompat : ColorStateList? = ColorStateList.valueOf(0)
13
+
14
+lateinit var preferenceStore : SharedPreferences

+ 117 - 0
app/src/main/java/io/r_a_d/radio2/alarm/RadioAlarm.kt View File

@@ -0,0 +1,117 @@
1
+package io.r_a_d.radio2.alarm
2
+
3
+import android.app.AlarmManager
4
+import android.app.PendingIntent
5
+import android.content.Context
6
+import android.content.Intent
7
+import android.util.Log
8
+import androidx.core.app.AlarmManagerCompat
9
+import io.r_a_d.radio2.BootBroadcastReceiver
10
+import androidx.preference.PreferenceManager
11
+import io.r_a_d.radio2.*
12
+import java.util.*
13
+
14
+class RadioAlarm {
15
+
16
+    companion object {
17
+        val instance by lazy {
18
+            RadioAlarm()
19
+        }
20
+    }
21
+    lateinit var alarmIntent: PendingIntent
22
+
23
+
24
+    fun cancelAlarm(c: Context)
25
+    {
26
+        if (::alarmIntent.isInitialized)
27
+        {
28
+            val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
29
+            alarmManager.cancel(alarmIntent)
30
+        }
31
+    }
32
+
33
+    fun setNextAlarm(c: Context, isForce: Boolean = false, forceTime: Int? = null, forceDays: Set<String>? = null)
34
+    {
35
+        // don't do anything if the preference is set to FALSE, of course.
36
+        if (!PreferenceManager.getDefaultSharedPreferences(c).getBoolean("isWakingUp", false) && !isForce)
37
+            return
38
+
39
+        val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
40
+        alarmIntent = Intent(c, BootBroadcastReceiver::class.java).let { intent ->
41
+            intent.putExtra("action", "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}")
42
+            PendingIntent.getBroadcast(c, 0, intent, 0)
43
+        }
44
+        val showIntent = Intent(c, ParametersActivity::class.java).let { intent ->
45
+            intent.putExtra("action", ActionOpenParam.ALARM.name)
46
+            PendingIntent.getActivity(c, 0, intent, 0)
47
+        }
48
+        val time = findNextAlarmTime(c, forceTime, forceDays)
49
+        if (time > 0)
50
+            AlarmManagerCompat.setAlarmClock(alarmManager, time, showIntent, alarmIntent)
51
+    }
52
+
53
+    fun findNextAlarmTime(c: Context, forceTime: Int? = null, forceDays: Set<String>? = null) : Long
54
+    {
55
+        val calendar = Calendar.getInstance()
56
+
57
+        val days = forceDays ?: PreferenceManager.getDefaultSharedPreferences(c).getStringSet("alarmDays", setOf())
58
+        val time = forceTime ?: PreferenceManager.getDefaultSharedPreferences(c).getInt("alarmTimeFromMidnight", 7*60) // default value is set to 07:00 AM
59
+
60
+        val hourOfDay = time / 60 //time is in minutes
61
+        val minute = time % 60
62
+
63
+        val fullWeekOrdered = arrayListOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")
64
+        val selectedDays = arrayListOf<Int>()
65
+        for (item in fullWeekOrdered)
66
+        {
67
+            if (days!!.contains(item))
68
+                selectedDays.add(fullWeekOrdered.indexOf(item))
69
+        }
70
+
71
+        if (selectedDays.isEmpty()) // in case the user uncheck all boxes... do nothing.
72
+            return 0
73
+
74
+
75
+        val currentDay = calendar.get(Calendar.DAY_OF_WEEK) - 1 // 0 (Sunday) to 6 (Saturday)
76
+        val datePassed = if (calendar.get(Calendar.HOUR_OF_DAY)*60 + calendar.get(Calendar.MINUTE) >= time ) 1 else 0
77
+        var nextSelectedDay = (currentDay + datePassed)%7
78
+        var i = 0 + datePassed
79
+        while (!selectedDays.contains(nextSelectedDay))
80
+        {
81
+            nextSelectedDay = (nextSelectedDay + 1)%7
82
+            i++
83
+        }
84
+        // We found out the next selected day in the list.
85
+        // we must move 'i' days forward
86
+        calendar.isLenient = true
87
+        calendar.set(calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH) + i, hourOfDay, minute)
88
+
89
+        Log.d(tag, calendar.toString())
90
+
91
+
92
+        return calendar.timeInMillis
93
+    }
94
+
95
+    fun snooze(c: Context)
96
+    {
97
+        val snoozeString = preferenceStore.getString("snoozeDuration", "10") ?: "10"
98
+        val snoozeMinutes = if (snoozeString == c.getString(R.string.disable)) 0  else Integer.parseInt(snoozeString)
99
+        if (snoozeMinutes > 0)
100
+        {
101
+            val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
102
+            alarmIntent = Intent(c, BootBroadcastReceiver::class.java).let { intent ->
103
+                intent.putExtra("action", "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}")
104
+                PendingIntent.getBroadcast(c, 0, intent, 0)
105
+            }
106
+            val showIntent = Intent(c, ParametersActivity::class.java).let { intent ->
107
+                intent.putExtra("action", "alarm")
108
+                PendingIntent.getActivity(c, 0, intent, 0)
109
+            }
110
+
111
+            AlarmManagerCompat.setAlarmClock(alarmManager, Calendar.getInstance().timeInMillis + (snoozeMinutes * 60 * 1000), showIntent, alarmIntent)
112
+
113
+            // now that the next alarm has been scheduled, kill the app
114
+            c.startService(Intent(c, RadioService::class.java).putExtra("action", Actions.KILL.name))
115
+        }
116
+    }
117
+}

+ 77 - 0
app/src/main/java/io/r_a_d/radio2/alarm/RadioSleeper.kt View File

@@ -0,0 +1,77 @@
1
+package io.r_a_d.radio2.alarm
2
+
3
+import android.app.AlarmManager
4
+import android.app.PendingIntent
5
+import android.content.Context
6
+import android.content.Intent
7
+import android.util.Log
8
+import androidx.core.app.AlarmManagerCompat
9
+import androidx.lifecycle.MutableLiveData
10
+import androidx.preference.PreferenceManager
11
+import io.r_a_d.radio2.*
12
+
13
+class RadioSleeper {
14
+
15
+    companion object {
16
+        val instance by lazy {
17
+            RadioSleeper()
18
+        }
19
+    }
20
+
21
+    val sleepAtMillis: MutableLiveData<Long?> = MutableLiveData()
22
+
23
+    init
24
+    {
25
+        // the companion object is lazy, and is invoked by a Ticker, so a background thread.
26
+        // we MUST use postValue to set it correctly.
27
+        sleepAtMillis.postValue(null)
28
+    }
29
+
30
+    private lateinit var sleepIntent: PendingIntent
31
+    private lateinit var fadeOutIntent: PendingIntent
32
+
33
+    fun setSleep(c: Context, isForce: Boolean = false, forceDuration: Long? = null)
34
+    {
35
+        // don't do anything if the preference is set to FALSE, of course.
36
+        if (!PreferenceManager.getDefaultSharedPreferences(c).getBoolean("isSleeping", false) && !isForce)
37
+            return
38
+
39
+        val minutes: Long = forceDuration ?: Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(c).getString("sleepDuration", "1") ?: "1").toLong()
40
+
41
+        val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
42
+        sleepIntent = Intent(c, RadioService::class.java).let { intent ->
43
+            intent.putExtra("action", Actions.KILL.name)
44
+            PendingIntent.getService(c, 99, intent, 0)
45
+        }
46
+
47
+        val currentMillis = System.currentTimeMillis()
48
+        if (minutes > 0)
49
+        {
50
+            AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, currentMillis + (minutes * 60 * 1000),  sleepIntent)
51
+            fadeOutIntent = Intent(c, RadioService::class.java).let { intent ->
52
+                intent.putExtra("action", Actions.FADE_OUT.name)
53
+                PendingIntent.getService(c, 98, intent, 0)
54
+            }
55
+            AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, currentMillis + (minutes * 60 * 1000) - (1 * 60 * 1000), fadeOutIntent)
56
+            sleepAtMillis.value = System.currentTimeMillis() + (minutes * 60 * 1000) - 1 // this -1 allows to round the division for display at the right integer
57
+            Log.d(tag, "set sleep to $minutes minutes")
58
+        }
59
+    }
60
+
61
+
62
+    fun cancelSleep(c: Context)
63
+    {
64
+        if (::sleepIntent.isInitialized)
65
+        {
66
+            val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
67
+            alarmManager.cancel(sleepIntent)
68
+            alarmManager.cancel(fadeOutIntent)
69
+
70
+            val cancelFadeOutIntent = Intent(c, RadioService::class.java).putExtra("action", Actions.CANCEL_FADE_OUT.name)
71
+            c.startService(cancelFadeOutIntent)
72
+
73
+            Log.d(tag, "cancelled sleep")
74
+        }
75
+        sleepAtMillis.value = null
76
+    }
77
+}

+ 304 - 0
app/src/main/java/io/r_a_d/radio2/playerstore/PlayerStore.kt View File

@@ -0,0 +1,304 @@
1
+package io.r_a_d.radio2.playerstore
2
+
3
+import android.content.Context
4
+import android.graphics.Bitmap
5
+import android.graphics.BitmapFactory
6
+import android.support.v4.media.session.PlaybackStateCompat
7
+import android.util.Log
8
+import androidx.lifecycle.MutableLiveData
9
+import androidx.lifecycle.ViewModel
10
+import io.r_a_d.radio2.*
11
+import org.json.JSONObject
12
+import java.io.IOException
13
+import java.io.InputStream
14
+import java.net.URL
15
+
16
+
17
+class PlayerStore {
18
+
19
+    val isPlaying: MutableLiveData<Boolean> = MutableLiveData()
20
+    val isServiceStarted: MutableLiveData<Boolean> = MutableLiveData()
21
+    val volume: MutableLiveData<Int> = MutableLiveData()
22
+    val playbackState: MutableLiveData<Int> = MutableLiveData()
23
+    val currentTime: MutableLiveData<Long> = MutableLiveData()
24
+    val streamerPicture: MutableLiveData<Bitmap> = MutableLiveData()
25
+    val streamerName: MutableLiveData<String> = MutableLiveData()
26
+    val currentSong : Song = Song()
27
+    val currentSongBackup: Song = Song()
28
+    val lp : ArrayList<Song> = ArrayList()
29
+    val queue : ArrayList<Song> = ArrayList()
30
+    val isQueueUpdated: MutableLiveData<Boolean> = MutableLiveData()
31
+    val isLpUpdated: MutableLiveData<Boolean> = MutableLiveData()
32
+    val isMuted : MutableLiveData<Boolean> = MutableLiveData()
33
+    val listenersCount: MutableLiveData<Int> = MutableLiveData()
34
+    private val urlToScrape = "https://r-a-d.io/api"
35
+    var latencyCompensator : Long = 0
36
+    var isInitialized: Boolean = false
37
+    var isStreamDown: Boolean = false
38
+
39
+    init {
40
+        playbackState.value = PlaybackStateCompat.STATE_STOPPED
41
+        isPlaying.value = false
42
+        isServiceStarted.value = false
43
+        streamerName.value = ""
44
+        volume.value = preferenceStore.getInt("volume", 100)
45
+        currentTime.value = System.currentTimeMillis()
46
+        isQueueUpdated.value = false
47
+        isLpUpdated.value = false
48
+        isMuted.value = false
49
+        currentSong.title.value = noConnectionValue
50
+        listenersCount.value = 0
51
+    }
52
+
53
+    // ##################################################
54
+    // ################# API FUNCTIONS ##################
55
+    // ##################################################
56
+
57
+    private fun updateApi(resMain: JSONObject, isCompensatingLatency : Boolean = false) {
58
+        // If we're not in PLAYING state, update title / artist metadata. If we're playing, the ICY will take care of that.
59
+        if (playbackState.value != PlaybackStateCompat.STATE_PLAYING || currentSong.title.value.isNullOrEmpty()
60
+            || currentSong.title.value == noConnectionValue)
61
+            currentSong.setTitleArtist(resMain.getString("np"))
62
+
63
+        // only update the value if the song has changed. This avoids to trigger observers when they shouldn't be triggered
64
+        if (currentSong.startTime.value != resMain.getLong("start_time")*1000)
65
+            currentSong.startTime.value = resMain.getLong("start_time")*1000
66
+
67
+        // I noticed that the server has a big (3 to 9 seconds !!) offset for current time.
68
+        // we can measure it when the player is playing, to compensate it and have our progress bar perfectly timed
69
+        // latencyCompensator is set to null when beginPlaying() (we can't measure it at the moment we start playing, since we're in the middle of a song),
70
+        // at this moment, we set it to 0. Then, next time the updateApi is called when we're playing, we measure the latency and we set out latencyComparator.
71
+        if(isCompensatingLatency)
72
+        {
73
+            latencyCompensator = resMain.getLong("current")*1000 - (currentSong.startTime.value ?: resMain.getLong("current")*1000)
74
+            Log.d(tag, playerStoreTag +  "latency compensator set to ${(latencyCompensator).toFloat()/1000} s")
75
+        }
76
+        currentSong.stopTime.value = resMain.getLong("end_time")*1000
77
+        currentTime.value = (resMain.getLong("current"))*1000 - (latencyCompensator)
78
+
79
+        val newStreamer = resMain.getJSONObject("dj").getString("djname")
80
+        if (newStreamer != streamerName.value)
81
+        {
82
+            val streamerPictureUrl =
83
+                "${urlToScrape}/dj-image/${resMain.getJSONObject("dj").getString("djimage")}"
84
+            fetchPicture(streamerPictureUrl)
85
+            streamerName.value = newStreamer
86
+        }
87
+        val listeners = resMain.getInt("listeners")
88
+        listenersCount.value = listeners
89
+        Log.d(tag, playerStoreTag +  "store updated")
90
+    }
91
+
92
+    private val scrape : (Any?) -> String =
93
+    {
94
+        URL(urlToScrape).readText()
95
+    }
96
+
97
+    /* initApi is called :
98
+        - at startup
99
+        - when a streamer changes.
100
+        the idea is to fetch the queue when a streamer changes (potentially Hanyuu), and at startup.
101
+        The Last Played is only fetched if it's empty (so, only at startup), not when a streamer changes.
102
+     */
103
+    fun initApi()
104
+    {
105
+        val post : (parameter: Any?) -> Unit = {
106
+            val result = JSONObject(it as String)
107
+            if (result.has("main"))
108
+            {
109
+                val resMain = result.getJSONObject("main")
110
+                updateApi(resMain)
111
+                currentSongBackup.copy(currentSong)
112
+                queue.clear()
113
+                if (resMain.has("queue") && resMain.getBoolean("isafkstream"))
114
+                {
115
+                    val queueJSON =
116
+                        resMain.getJSONArray("queue")
117
+                    for (i in 0 until queueJSON.length())
118
+                    {
119
+                        val t = extractSong(queueJSON[i] as JSONObject)
120
+                        if (t != currentSong) // if the API is too slow and didn't remove the first song from queue...
121
+                            queue.add(queue.size, t)
122
+                    }
123
+                }
124
+                isQueueUpdated.value = true
125
+                Log.d(tag, playerStoreTag +  queue.toString())
126
+
127
+                if (resMain.has("lp"))
128
+                {
129
+                    val queueJSON =
130
+                        resMain.getJSONArray("lp")
131
+                    // if my stack is empty, I fill it entirely (startup)
132
+                    if (lp.isEmpty())
133
+                    {
134
+                        for (i in 0 until queueJSON.length())
135
+                            lp.add(lp.size, extractSong(queueJSON[i] as JSONObject))
136
+                    }
137
+                }
138
+                Log.d(tag, playerStoreTag +  lp.toString())
139
+                isLpUpdated.value = true
140
+            }
141
+            isInitialized = true
142
+        }
143
+        Async(scrape, post)
144
+    }
145
+
146
+    fun fetchApi(isCompensatingLatency: Boolean = false) {
147
+        val post: (parameter: Any?) -> Unit = {
148
+            val result = JSONObject(it as String)
149
+            if (!result.isNull("main"))
150
+            {
151
+                val res = result.getJSONObject("main")
152
+                updateApi(res, isCompensatingLatency)
153
+            }
154
+        }
155
+        Async(scrape, post)
156
+    }
157
+
158
+    // ##################################################
159
+    // ############## QUEUE / LP FUNCTIONS ##############
160
+    // ##################################################
161
+
162
+    fun updateLp() {
163
+        // note : lp must never be empty. There should always be some songs "last played".
164
+        // if not, then the function has been called before initialization. No need to do anything.
165
+        if (lp.isNotEmpty()){
166
+            val n = Song()
167
+            n.copy(currentSongBackup)
168
+            lp.add(0, n)
169
+            currentSongBackup.copy(currentSong)
170
+            isLpUpdated.value = true
171
+            Log.d(tag, playerStoreTag +  lp.toString())
172
+        }
173
+    }
174
+
175
+    fun updateQueue() {
176
+        if (queue.isNotEmpty()) {
177
+            queue.remove(queue.first())
178
+            Log.d(tag, playerStoreTag + queue.toString())
179
+            fetchLastRequest()
180
+            isQueueUpdated.value = true
181
+        } else if (isInitialized) {
182
+            fetchLastRequest()
183
+        } else {
184
+            Log.d(tag, playerStoreTag +  "queue is empty!")
185
+        }
186
+    }
187
+
188
+    private fun fetchLastRequest()
189
+    {
190
+        val sleepScrape: (Any?) -> String = {
191
+            /* we can maximize our chances to retrieve the last queued song by specifically waiting for the number of seconds we measure between ICY metadata and API change.
192
+             we add 2 seconds just to get a higher probability that the API has correctly updated. (the latency compensator can have a jitter of 1 second usually)
193
+             If, against all odds, the API hasn't updated yet, we will retry in the same amount of seconds. So we'll have the data anyway.
194
+            This way to fetch at the most probable time is a good compromise between fetch speed and fetch frequency
195
+            We don't fetch too often, and we start to fetch at the most *probable* time.
196
+            If there's no latencyCompensator measured yet, we only wait for 3 seconds.
197
+            If the song is the same, it will be called again. 3 seconds is a good compromise between speed and frequency:
198
+            it might be called twice, rarely 3 times, and it's only the 2 first songs ; after these, the latencyCompensator is set to fetch at the most probable time.
199
+             */
200
+            val sleepTime: Long = if (latencyCompensator > 0) latencyCompensator + 2000 else 3000
201
+            Thread.sleep(sleepTime) // we wait a bit (10s) for the API to get updated on R/a/dio side!
202
+            URL(urlToScrape).readText()
203
+        }
204
+
205
+        lateinit var post: (parameter: Any?) -> Unit
206
+
207
+        fun postFun(result: JSONObject)
208
+        {
209
+            if (result.has("main")) {
210
+                val resMain = result.getJSONObject("main")
211
+                if ((resMain.has("isafkstream") && !resMain.getBoolean("isafkstream")) &&
212
+                    queue.isNotEmpty())
213
+                {
214
+                    queue.clear() //we're not requesting anything anymore.
215
+                    isQueueUpdated.value = true
216
+                } else if (resMain.has("isafkstream") && resMain.getBoolean("isafkstream") &&
217
+                    queue.isEmpty())
218
+                {
219
+                    initApi()
220
+                } else if (resMain.has("queue") && queue.isNotEmpty()) {
221
+                    val queueJSON =
222
+                        resMain.getJSONArray("queue")
223
+                    val t = extractSong(queueJSON[4] as JSONObject)
224
+                    if (t == queue.last())
225
+                    {
226
+                        Log.d(tag, playerStoreTag +  "Song already in there: $t")
227
+                        Async(sleepScrape, post)
228
+                    } else {
229
+                        queue.add(queue.size, t)
230
+                        Log.d(tag, playerStoreTag +  "added last queue song: $t")
231
+                        isQueueUpdated.value = true
232
+                    }
233
+                }
234
+            }
235
+        }
236
+
237
+        post = {
238
+            val result = JSONObject(it as String)
239
+            /*  The goal is to pass the result to a function that will process it (postFun).
240
+                The magic trick is, under circumstances, the last queue song might not have been updated yet when we fetch it.
241
+                So if this is detected ==> if (t == queue.last() )
242
+                Then the function re-schedule an Async(sleepScrape, post).
243
+                To do that, the "post" must be defined BEFORE the function, but the function must be defined BEFORE the "post" value.
244
+                So I declare "post" as lateinit var, define the function, then define the "post" that calls the function. IT SHOULD WORK.
245
+             */
246
+            postFun(result)
247
+        }
248
+
249
+        Async(sleepScrape, post)
250
+    }
251
+
252
+    private fun extractSong(songJSON: JSONObject) : Song {
253
+        val song = Song()
254
+        song.setTitleArtist(songJSON.getString("meta"))
255
+        song.startTime.value = songJSON.getLong("timestamp")
256
+        song.stopTime.value = song.startTime.value
257
+        song.type.value = songJSON.getInt("type")
258
+        return song
259
+    }
260
+
261
+    // ##################################################
262
+    // ############## PICTURE FUNCTIONS #################
263
+    // ##################################################
264
+
265
+    private fun fetchPicture(fileUrl: String)
266
+    {
267
+        val scrape: (Any?) -> Bitmap? = {
268
+            var k: InputStream? = null
269
+            var pic: Bitmap? = null
270
+            try {
271
+                k = URL(fileUrl).content as InputStream
272
+                val options = BitmapFactory.Options()
273
+                options.inSampleSize = 1
274
+                // this makes 1/2 of origin image size from width and height.
275
+                // it alleviates the memory for API16-API19 especially
276
+                pic = BitmapFactory.decodeStream(k, null, options)
277
+                k.close()
278
+            } catch (e: IOException) {
279
+                e.printStackTrace()
280
+            } finally {
281
+                k?.close()
282
+            }
283
+            pic
284
+        }
285
+        val post : (parameter: Any?) -> Unit = {
286
+            streamerPicture.postValue(it as Bitmap?)
287
+        }
288
+        Async(scrape, post)
289
+    }
290
+
291
+    fun initPicture(c: Context) {
292
+        streamerPicture.value = BitmapFactory.decodeResource(c.resources,
293
+            R.drawable.actionbar_logo
294
+        )
295
+    }
296
+
297
+    private val playerStoreTag = "====PlayerStore===="
298
+    companion object {
299
+        val instance by lazy {
300
+            PlayerStore()
301
+        }
302
+    }
303
+}
304
+

+ 58 - 0
app/src/main/java/io/r_a_d/radio2/playerstore/Song.kt View File

@@ -0,0 +1,58 @@
1
+package io.r_a_d.radio2.playerstore
2
+
3
+import androidx.lifecycle.MutableLiveData
4
+
5
+class Song(artistTitle: String = "", _id : Int = 0, _isRequestable : Boolean = false) {
6
+
7
+    // TODO : remove MutableLiveData, use a MutableLiveData Boolean on PlayerStore instead
8
+    val title: MutableLiveData<String> = MutableLiveData()
9
+    val artist: MutableLiveData<String> = MutableLiveData()
10
+    val type: MutableLiveData<Int> = MutableLiveData()
11
+    val startTime: MutableLiveData<Long> = MutableLiveData()
12
+    val stopTime: MutableLiveData<Long> = MutableLiveData()
13
+    var id: Int? = _id
14
+    var isRequestable : Boolean = _isRequestable
15
+
16
+    init {
17
+        setTitleArtist(artistTitle)
18
+        type.value = 0
19
+        startTime.value =  System.currentTimeMillis()
20
+        stopTime.value = System.currentTimeMillis() + 1000
21
+    }
22
+
23
+    override fun toString() : String {
24
+        return "id=$id | ${artist.value} - ${title.value} | type=${type.value} | times ${startTime.value} - ${stopTime.value}\n"
25
+    }
26
+
27
+    fun setTitleArtist(data: String)
28
+    {
29
+        val hyphenPos = data.indexOf(" - ")
30
+        try {
31
+            if (hyphenPos < 0)
32
+                throw ArrayIndexOutOfBoundsException()
33
+            if (artist.value != data.substring(0, hyphenPos))
34
+                artist.value = data.substring(0, hyphenPos)
35
+            if (title.value != data.substring(hyphenPos + 3))
36
+                title.value = data.substring(hyphenPos + 3)
37
+        } catch (e: Exception) {
38
+            if (artist.value != "")
39
+                artist.value = ""
40
+            if (title.value != data)
41
+                title.value = data
42
+        }
43
+    }
44
+
45
+    override fun equals(other: Any?) : Boolean
46
+    {
47
+        val song: Song = other as Song
48
+        return this.title.value == song.title.value && this.artist.value == song.artist.value
49
+    }
50
+
51
+    fun copy(song: Song) {
52
+        this.title.value = song.title.value
53
+        this.artist.value = song.artist.value
54
+        this.startTime.value = song.startTime.value
55
+        this.stopTime.value = song.stopTime.value
56
+        this.type.value = song.type.value
57
+    }
58
+}

+ 132 - 0
app/src/main/java/io/r_a_d/radio2/preferences/AlarmFragment.kt View File

@@ -0,0 +1,132 @@
1
+package io.r_a_d.radio2.preferences
2
+
3
+import android.app.TimePickerDialog
4
+import android.os.Bundle
5
+import android.util.Log
6
+import androidx.core.content.edit
7
+import androidx.preference.*
8
+import io.r_a_d.radio2.R
9
+import io.r_a_d.radio2.alarm.RadioAlarm
10
+import java.util.*
11
+
12
+class AlarmFragment : PreferenceFragmentCompat() {
13
+    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
14
+        setPreferencesFromResource(R.xml.alarm_preferences, rootKey)
15
+
16
+        val timeSet = findPreference<Preference>("timeSet")
17
+        val isWakingUp = findPreference<SwitchPreferenceCompat>("isWakingUp")
18
+        val alarmDays = findPreference<MultiSelectListPreference>("alarmDays")
19
+        val snoozeDuration = findPreference<ListPreference>("snoozeDuration")
20
+
21
+
22
+        fun updateIsWakingUpSummary(preference: SwitchPreferenceCompat?, newValue: Boolean? = true,  forceTime: Int? = null, forceDays: Set<String>? = null)
23
+        {
24
+            val dateLong = RadioAlarm.instance.findNextAlarmTime(context!!, forceTime, forceDays)
25
+            val calendar = Calendar.getInstance()
26
+            calendar.timeInMillis = dateLong
27
+            if (newValue == true && calendar.timeInMillis > 0)
28
+            {
29
+                val fullWeekOrdered = arrayListOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")
30
+                preference?.summary = "Next alarm on ${fullWeekOrdered[calendar.get(Calendar.DAY_OF_WEEK) - 1]} " +
31
+                        "at ${if (calendar.get(Calendar.HOUR_OF_DAY) < 10) "0" else ""}${calendar.get(Calendar.HOUR_OF_DAY)}" +
32
+                        ":${if (calendar.get(Calendar.MINUTE) < 10) "0" else ""}${calendar.get(Calendar.MINUTE)}"
33
+            } else if (newValue == true)
34
+                preference?.summary = "Select at least one day"
35
+            else
36
+            {
37
+                preference?.summary = "No alarm set"
38
+            }
39
+        }
40
+
41
+
42
+        val hourOfDayDefault = 7
43
+        val minuteDefault = 0
44
+        if (!PreferenceManager.getDefaultSharedPreferences(context!!).contains("alarmTimeFromMidnight"))
45
+        {
46
+            PreferenceManager.getDefaultSharedPreferences(context!!).edit {
47
+                putInt("alarmTimeFromMidnight", (60*hourOfDayDefault+minuteDefault))
48
+                commit()
49
+            }
50
+        }
51
+        val time = PreferenceManager.getDefaultSharedPreferences(context!!).getInt("alarmTimeFromMidnight", (60*hourOfDayDefault+minuteDefault))
52
+        val hourOfDay = time / 60
53
+        val minute = time % 60
54
+        timeSet?.summary = "${if (hourOfDay < 10) "0" else ""}$hourOfDay:${if (minute < 10) "0" else ""}$minute"
55
+
56
+
57
+        timeSet?.setOnPreferenceClickListener {
58
+            val timePickerOnTimeSetListener = TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute ->
59
+                PreferenceManager.getDefaultSharedPreferences(context!!).edit {
60
+                    putInt("alarmTimeFromMidnight", (60*hourOfDay+minute))
61
+                    commit()
62
+                }
63
+                timeSet.summary = "${if (hourOfDay < 10) "0" else ""}$hourOfDay:${if (minute < 10) "0" else ""}$minute"
64
+                RadioAlarm.instance.cancelAlarm(context!!)
65
+                RadioAlarm.instance.setNextAlarm(context!!, isForce = true, forceTime = hourOfDay*60+minute)
66
+                updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked, forceTime = hourOfDay*60+minute)
67
+            }
68
+            val timeNew = PreferenceManager.getDefaultSharedPreferences(context!!).getInt("alarmTimeFromMidnight", 7*60)
69
+            val hourOfDayNew = timeNew / 60
70
+            val minuteNew = timeNew % 60
71
+            val timePicker = TimePickerDialog(context, timePickerOnTimeSetListener, hourOfDayNew, minuteNew, true)
72
+
73
+            timePicker.show()
74
+            true
75
+        }
76
+
77
+        fun updateDays(preference : MultiSelectListPreference?, newValue : Set<String>?)
78
+        {
79
+            Log.d(tag, newValue.toString())
80
+            val listOfDays : String
81
+            val fullWeek = context!!.resources.getStringArray(R.array.weekdays).toSet()
82
+            val workingWeek = context!!.resources.getStringArray(R.array.weekdays).toSet().minusElement("Saturday").minusElement("Sunday")
83
+            listOfDays = when (newValue) {
84
+                fullWeek -> context!!.getString(R.string.every_day)
85
+                workingWeek -> context!!.getString(R.string.working_days)
86
+                else -> {
87
+                    // build ORDERED LIST of days... I don't know why the original one is in shambles!!
88
+                    val fullWeekOrdered = arrayListOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
89
+                    val selectedDays = arrayListOf<String>()
90
+                    for (item in fullWeekOrdered) {
91
+                        if (newValue!!.contains(item))
92
+                            selectedDays.add(item)
93
+                    }
94
+                    "$selectedDays".drop(1).dropLast(1) // dropping '[' and ']'
95
+                }
96
+            }
97
+            preference?.summary = listOfDays
98
+        }
99
+
100
+        updateDays(alarmDays, PreferenceManager.getDefaultSharedPreferences(context).getStringSet("alarmDays", setOf()))
101
+        alarmDays?.setOnPreferenceChangeListener { preference, newValue ->
102
+            @Suppress("UNCHECKED_CAST")
103
+            updateDays(preference as MultiSelectListPreference, newValue as Set<String>)
104
+            RadioAlarm.instance.cancelAlarm(context!!)
105
+            RadioAlarm.instance.setNextAlarm(context!!, isForce = true, forceDays = newValue)
106
+            updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked, forceDays = newValue)
107
+            true
108
+        }
109
+
110
+        updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked)
111
+
112
+        isWakingUp?.setOnPreferenceChangeListener { _, newValue ->
113
+            if (newValue as Boolean)
114
+                RadioAlarm.instance.setNextAlarm(context!!, isForce = true)
115
+            else
116
+                RadioAlarm.instance.cancelAlarm(context!!)
117
+            timeSet?.isEnabled = newValue
118
+            alarmDays?.isEnabled = newValue
119
+            snoozeDuration?.isEnabled = newValue
120
+            updateIsWakingUpSummary(isWakingUp, newValue)
121
+            true
122
+        }
123
+
124
+
125
+        alarmDays?.isEnabled = isWakingUp?.isChecked ?: false
126
+        timeSet?.isEnabled = isWakingUp?.isChecked ?: false
127
+        snoozeDuration?.isEnabled = isWakingUp?.isChecked ?: false
128
+
129
+    }
130
+
131
+
132
+}

+ 80 - 0
app/src/main/java/io/r_a_d/radio2/preferences/CustomizeFragment.kt View File

@@ -0,0 +1,80 @@
1
+package io.r_a_d.radio2.preferences
2
+
3
+import android.content.Intent
4
+import android.net.Uri
5
+import android.os.Bundle
6
+import android.util.Log
7
+import androidx.appcompat.app.AlertDialog
8
+import androidx.preference.*
9
+import io.r_a_d.radio2.R
10
+import io.r_a_d.radio2.preferenceStore
11
+import io.r_a_d.radio2.ui.songs.request.Requestor
12
+
13
+class CustomizeFragment : PreferenceFragmentCompat() {
14
+    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
15
+        setPreferencesFromResource(R.xml.customize_preferences, rootKey)
16
+
17
+        val userNamePref = preferenceScreen.findPreference<EditTextPreference>("userName")
18
+        userNamePref?.summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
19
+        userNamePref?.setOnPreferenceChangeListener { _, newValue ->
20
+            val name = newValue as String
21
+            Requestor.instance.initFavorites(name) // need to be as parameter cause the callback is called BEFORE PARAMETER SET
22
+            true
23
+        }
24
+
25
+        val snackbarPersistent = preferenceScreen.findPreference<SwitchPreferenceCompat>("snackbarPersistent")
26
+        snackbarPersistent!!.summary = if (preferenceStore.getBoolean("snackbarPersistent", true))
27
+            getString(R.string.snackbarPersistent)
28
+        else
29
+            getString(R.string.snackbarNonPersistent)
30
+        snackbarPersistent.setOnPreferenceChangeListener { preference, newValue ->
31
+            if (newValue as Boolean)
32
+                preference.setSummary(R.string.snackbarPersistent)
33
+            else
34
+                preference.setSummary(R.string.snackbarNonPersistent)
35
+            true
36
+        }
37
+
38
+        val splitLayout = preferenceScreen.findPreference<SwitchPreferenceCompat>("splitLayout")
39
+        splitLayout!!.summary = if (preferenceStore.getBoolean("splitLayout", true))
40
+            getString(R.string.split_layout)
41
+        else
42
+            getString(R.string.not_split_layout)
43
+        splitLayout.setOnPreferenceChangeListener { preference, newValue ->
44
+            if (newValue as Boolean)
45
+                preference.setSummary(R.string.split_layout)
46
+            else
47
+                preference.setSummary(R.string.not_split_layout)
48
+            true
49
+        }
50
+
51
+        val helpFavorites = preferenceScreen.findPreference<Preference>("helpFavorites")
52
+        helpFavorites?.setOnPreferenceClickListener { _ ->
53
+            val url = getString(R.string.github_url_wiki_irc_for_favorites)
54
+            val i = Intent(Intent.ACTION_VIEW)
55
+            i.data = Uri.parse(url)
56
+            startActivity(i)
57
+            true
58
+        }
59
+
60
+        val fetchPeriod = preferenceScreen.findPreference<ListPreference>("fetchPeriod")
61
+        fetchPeriod?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
62
+        fetchPeriod?.setOnPreferenceChangeListener { _, newValue ->
63
+            val builder1 = AlertDialog.Builder(context!!)
64
+            if (Integer.parseInt(newValue as String) == 0)
65
+                builder1.setMessage(R.string.fetch_disabled_restart_the_app)
66
+            else
67
+                builder1.setMessage(R.string.restart_the_app)
68
+            builder1.setCancelable(true)
69
+
70
+            builder1.setPositiveButton("Close" ) { dialog, _ ->
71
+                dialog.cancel()
72
+            }
73
+
74
+            val alert11 = builder1.create()
75
+            alert11.show()
76
+            true
77
+        }
78
+
79
+    }
80
+}

+ 27 - 0
app/src/main/java/io/r_a_d/radio2/preferences/MainPreferenceFragment.kt View File

@@ -0,0 +1,27 @@
1
+package io.r_a_d.radio2.preferences
2
+
3
+import android.content.Intent
4
+import android.net.Uri
5
+import android.os.Bundle
6
+import io.r_a_d.radio2.R
7
+import android.annotation.SuppressLint
8
+import androidx.preference.*
9
+
10
+class MainPreferenceFragment : PreferenceFragmentCompat() {
11
+
12
+    @SuppressLint("ApplySharedPref")
13
+    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
14
+        setPreferencesFromResource(R.xml.preferences, rootKey)
15
+        preferenceScreen.isIconSpaceReserved = false
16
+
17
+        val submitBug = preferenceScreen.findPreference<Preference>("submitBug")
18
+        submitBug!!.setOnPreferenceClickListener {
19
+            val url = getString(R.string.github_url_new_issue)
20
+            val i = Intent(Intent.ACTION_VIEW)
21
+            i.data = Uri.parse(url)
22
+            startActivity(i)
23
+            true
24
+        }
25
+
26
+    }
27
+}

+ 44 - 0
app/src/main/java/io/r_a_d/radio2/preferences/SleepFragment.kt View File

@@ -0,0 +1,44 @@
1
+package io.r_a_d.radio2.preferences
2
+
3
+import android.os.Bundle
4
+import androidx.preference.*
5
+import io.r_a_d.radio2.R
6
+import android.text.InputType
7
+import io.r_a_d.radio2.alarm.RadioSleeper
8
+
9
+
10
+class SleepFragment : PreferenceFragmentCompat() {
11
+    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
12
+        setPreferencesFromResource(R.xml.sleep_preference, rootKey)
13
+
14
+        val durationBeforeSleep = findPreference<EditTextPreference>("sleepDuration")
15
+        val isSleeping = findPreference<SwitchPreferenceCompat>("isSleeping")
16
+
17
+        isSleeping?.setOnPreferenceChangeListener { _, newValue ->
18
+            if (!(newValue as Boolean))
19
+                RadioSleeper.instance.cancelSleep(context!!)
20
+            else
21
+                RadioSleeper.instance.setSleep(context!!, isForce = true)
22
+
23
+            true
24
+        }
25
+
26
+
27
+        durationBeforeSleep?.setOnBindEditTextListener {
28
+            it.inputType = InputType.TYPE_CLASS_NUMBER
29
+        }
30
+
31
+        durationBeforeSleep?.summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
32
+        durationBeforeSleep?.setOnPreferenceChangeListener {_, newValue ->
33
+            val time = Integer.parseInt(newValue as String)
34
+            if (time > 0)
35
+            {
36
+                RadioSleeper.instance.setSleep(context!!, isForce = true, forceDuration = time.toLong())
37
+                isSleeping?.isChecked = true
38
+                true
39
+            } else {
40
+                false
41
+            }
42
+        }
43
+    }
44
+}

+ 63 - 0
app/src/main/java/io/r_a_d/radio2/preferences/StreamerNotifServiceFragment.kt View File

@@ -0,0 +1,63 @@
1
+package io.r_a_d.radio2.preferences
2
+
3
+import android.os.Bundle
4
+import androidx.appcompat.app.AlertDialog
5
+import androidx.preference.*
6
+import io.r_a_d.radio2.R
7
+import io.r_a_d.radio2.preferenceStore
8
+import io.r_a_d.radio2.streamerNotificationService.WorkerStore
9
+import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor
10
+import io.r_a_d.radio2.streamerNotificationService.stopStreamerMonitor
11
+
12
+class StreamerNotifServiceFragment : PreferenceFragmentCompat() {
13
+    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
14
+        setPreferencesFromResource(R.xml.streamer_notif_service_preferences, rootKey)
15
+
16
+
17
+        val streamerPeriod = preferenceScreen.findPreference<Preference>("streamerMonitorPeriodPref")
18
+
19
+        val streamerNotification = preferenceScreen.findPreference<Preference>("newStreamerNotification")
20
+        streamerNotification?.setOnPreferenceChangeListener { _, newValue ->
21
+            if ((newValue as Boolean)) {
22
+                val builder1 = AlertDialog.Builder(context!!)
23
+                builder1.setMessage(R.string.warningStreamerNotif)
24
+                builder1.setCancelable(false)
25
+                builder1.setPositiveButton(
26
+                    "Yes"
27
+                ) { dialog, _ ->
28
+                    startStreamerMonitor(context!!, force = true) // force enabled because the preference value is not yet set when running this callback.
29
+                    streamerPeriod?.isEnabled = true
30
+                    dialog.cancel()
31
+                }
32
+
33
+                builder1.setNegativeButton(
34
+                    "No"
35
+                ) { dialog, _ ->
36
+
37
+                    stopStreamerMonitor(context!!)
38
+                    (streamerNotification as SwitchPreferenceCompat).isChecked = false
39
+                    dialog.cancel()
40
+                }
41
+
42
+                val alert11 = builder1.create()
43
+                alert11.show()
44
+            }
45
+            else {
46
+                stopStreamerMonitor(context!!)
47
+                streamerPeriod?.isEnabled = false
48
+                WorkerStore.instance.isServiceStarted = false
49
+            }
50
+            true
51
+        }
52
+
53
+        streamerPeriod?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
54
+        streamerPeriod?.isEnabled = preferenceStore.getBoolean("newStreamerNotification", true)
55
+        streamerPeriod?.setOnPreferenceChangeListener { _, newValue ->
56
+            WorkerStore.instance.tickerPeriod = (Integer.parseInt(newValue as String)).toLong() * 60
57
+            // this should be sufficient, the next alarm schedule should take the new tickerPeriod.
58
+            true
59
+        }
60
+
61
+
62
+    }
63
+}

+ 41 - 0
app/src/main/java/io/r_a_d/radio2/streamerNotificationService/ServiceNotification.kt View File

@@ -0,0 +1,41 @@
1
+package io.r_a_d.radio2.streamerNotificationService
2
+
3
+import android.content.Context
4
+import io.r_a_d.radio2.BaseNotification
5
+import java.util.*
6
+
7
+class ServiceNotification(
8
+    notificationChannelId: String,
9
+    notificationChannel : Int,
10
+    notificationId: Int,
11
+    notificationImportance: Int
12
+
13
+) : BaseNotification
14
+    (
15
+    notificationChannelId,
16
+    notificationChannel,
17
+    notificationId,
18
+    notificationImportance
19
+){
20
+    
21
+    override fun create(c: Context)
22
+    {
23
+        super.create(c)
24
+        update()
25
+    }
26
+
27
+    fun update()
28
+    {
29
+        val date = Date()   // given date
30
+        val calendar = Calendar.getInstance() // creates a new calendar instance
31
+        calendar.time = date   // assigns calendar to given date
32
+        val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format
33
+        //val hours_american = calendar.get(Calendar.HOUR)        // gets hour in 12h format
34
+        val minutes = calendar.get(Calendar.MINUTE)
35
+        val seconds = calendar.get(Calendar.SECOND)
36
+
37
+        builder.setContentTitle("Never miss a stream! Current: ${WorkerStore.instance.streamerName.value}")
38
+        builder.setContentText("Last update: ${hours}:${minutes}:${seconds}")
39
+        notification = builder.build()
40
+    }
41
+}

+ 114 - 0
app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorExtensions.kt View File

@@ -0,0 +1,114 @@
1
+package io.r_a_d.radio2.streamerNotificationService
2
+
3
+import android.app.AlarmManager
4
+import android.app.PendingIntent
5
+import android.content.BroadcastReceiver
6
+import android.content.Context
7
+import android.content.Intent
8
+import android.os.Build
9
+import android.os.SystemClock
10
+import android.util.Log
11
+import androidx.core.app.NotificationCompat
12
+import androidx.preference.PreferenceManager
13
+import io.r_a_d.radio2.*
14
+import io.r_a_d.radio2.alarm.RadioAlarm
15
+import io.r_a_d.radio2.playerstore.PlayerStore
16
+import org.json.JSONObject
17
+import java.net.URL
18
+
19
+fun startNextAlarmStreamer(c: Context){
20
+    // the notification works with an alarm re-scheduled at fixed rate.
21
+    // if the service stopped, the alarm is not re-scheduled.
22
+    if (WorkerStore.instance.isServiceStarted)
23
+    {
24
+        val alarmIntent = Intent(c, StreamerMonitorService::class.java).let { intent ->
25
+            intent.putExtra("action", Actions.NOTIFY.name)
26
+            PendingIntent.getService(c, 0, intent, 0)
27
+        }
28
+
29
+        val alarmMgr = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager
30
+        when {
31
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> alarmMgr.setExactAndAllowWhileIdle(
32
+                AlarmManager.ELAPSED_REALTIME_WAKEUP,
33
+                SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000,
34
+                alarmIntent
35
+            )
36
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT -> alarmMgr.setExact(
37
+                AlarmManager.ELAPSED_REALTIME_WAKEUP,
38
+                SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000,
39
+                alarmIntent
40
+            )
41
+            else -> alarmMgr.set(
42
+                AlarmManager.ELAPSED_REALTIME_WAKEUP,
43
+                SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000,
44
+                alarmIntent
45
+            )
46
+        }
47
+    } else {
48
+        Log.d(tag, "alarm called while service is dead - skipped.")
49
+    }
50
+}
51
+
52
+fun stopStreamerMonitor(context: Context)
53
+{
54
+    val intent = Intent(context, StreamerMonitorService::class.java)
55
+    intent.putExtra("action", Actions.KILL.name)
56
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
57
+        context.startService(intent)
58
+    } else {
59
+        context.startService(intent)
60
+    }
61
+
62
+    Log.i(tag, "Service stopped")
63
+}
64
+
65
+fun startStreamerMonitor(context: Context, force: Boolean = false)
66
+{
67
+    if (!force)
68
+    {
69
+        val isNotifyingForNewStreamer = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("newStreamerNotification", false)
70
+        if (!isNotifyingForNewStreamer)
71
+                return
72
+    }
73
+
74
+    val intent = Intent(context, StreamerMonitorService::class.java)
75
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
76
+        context.startForegroundService(intent)
77
+    } else {
78
+        context.startService(intent)
79
+    }
80
+
81
+    Log.i(tag, "Service started on boot")
82
+}
83
+
84
+fun fetchStreamer(applicationContext: Context) {
85
+    val urlToScrape = "https://r-a-d.io/api"
86
+    val scrape : (Any?) -> String =
87
+        {
88
+            URL(urlToScrape).readText()
89
+        }
90
+    val post: (parameter: Any?) -> Unit = {
91
+        val result = JSONObject(it as String)
92
+        if (!result.isNull("main"))
93
+        {
94
+            val name = result.getJSONObject("main").getJSONObject("dj").getString("djname")
95
+            WorkerStore.instance.streamerName.value = name
96
+        }
97
+    }
98
+
99
+    // notify
100
+    val t = ServiceNotification(
101
+        notificationChannelId = applicationContext.getString(R.string.streamerServiceChannelId),
102
+        notificationChannel = R.string.streamerServiceChannel,
103
+        notificationId = 2,
104
+        notificationImportance = NotificationCompat.PRIORITY_LOW
105
+    )
106
+    t.create(applicationContext)
107
+    t.show()
108
+
109
+    try{
110
+        Async(scrape, post)
111
+        Log.d(tag, "enqueue next work in ${WorkerStore.instance.tickerPeriod} seconds")
112
+    } catch (e: Exception) {
113
+    }
114
+}

+ 123 - 0
app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorService.kt View File

@@ -0,0 +1,123 @@
1
+package io.r_a_d.radio2.streamerNotificationService
2
+
3
+
4
+import android.app.Service
5
+import android.content.Intent
6
+import android.os.Build
7
+import android.os.IBinder
8
+import android.util.Log
9
+import androidx.core.app.NotificationCompat
10
+import androidx.lifecycle.Observer
11
+import androidx.preference.PreferenceManager
12
+import io.r_a_d.radio2.Actions
13
+import io.r_a_d.radio2.R
14
+import io.r_a_d.radio2.tag
15
+import java.util.*
16
+
17
+class StreamerMonitorService : Service() {
18
+    override fun onBind(intent: Intent): IBinder? {
19
+        return null     // no binding allowed nor needed
20
+    }
21
+    private val streamerNameObserver: Observer<String> = Observer {
22
+        val previousStreamer: String
23
+        if (PreferenceManager.getDefaultSharedPreferences(this).contains("streamerName"))
24
+        {
25
+            previousStreamer = PreferenceManager.getDefaultSharedPreferences(this).getString("streamerName", "") ?: ""
26
+            /* 3 conditions:
27
+                - the streamer changed from previously
28
+                - there is a previous non-empty streamer (at least second time running it)
29
+                - the current streamer is non-empty (this can happen at Activity start where init() is called)
30
+             */
31
+            if (previousStreamer != it && previousStreamer != "" && it != "")
32
+            {
33
+                // notify
34
+                val newStreamer = StreamerNotification(
35
+                    notificationChannelId = this.getString(R.string.streamerNotificationChannelId),
36
+                    notificationChannel = R.string.streamerNotificationChannel,
37
+                    notificationId = 3,
38
+                    notificationImportance = NotificationCompat.PRIORITY_DEFAULT
39
+                )
40
+                newStreamer.create(this)
41
+                newStreamer.show()
42
+            }
43
+        }
44
+
45
+        with(PreferenceManager.getDefaultSharedPreferences(this).edit()){
46
+            putString("streamerName", it)
47
+            commit()
48
+        }
49
+    }
50
+
51
+    override fun onCreate() {
52
+        super.onCreate()
53
+        val streamerMonitorNotification = ServiceNotification(
54
+            notificationChannelId = this.getString(R.string.streamerServiceChannelId),
55
+            notificationChannel = R.string.streamerServiceChannel,
56
+            notificationId = 2,
57
+            notificationImportance = NotificationCompat.PRIORITY_LOW
58
+        )
59
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
60
+        {
61
+            streamerMonitorNotification.create(this)
62
+            streamerMonitorNotification.update()
63
+            streamerMonitorNotification.show()
64
+            startForeground(2, streamerMonitorNotification.notification)
65
+        }
66
+
67
+        WorkerStore.instance.tickerPeriod = 60 *
68
+                (if (PreferenceManager.getDefaultSharedPreferences(this).contains("streamerMonitorPeriodPref"))
69
+                    Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(this).getString("streamerMonitorPeriodPref", "15")!!).toLong()
70
+                else
71
+                    15)
72
+        Log.d(tag, "tickerPeriod = ${WorkerStore.instance.tickerPeriod}")
73
+
74
+        with(PreferenceManager.getDefaultSharedPreferences(this).edit()){
75
+            remove("streamerName")
76
+            commit() // I commit on main thread to be sure it's been updated before continuing.
77
+        }
78
+        WorkerStore.instance.streamerName.observeForever(streamerNameObserver)
79
+        WorkerStore.instance.isServiceStarted = true
80
+        startNextAlarmStreamer(this)
81
+        Log.d(tag, "streamerMonitor created")
82
+    }
83
+
84
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
85
+        val isNotifyingForNewStreamer = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("newStreamerNotification", false)
86
+
87
+        // it's probably redundant but it shouldn't hurt
88
+        if (!isNotifyingForNewStreamer || !WorkerStore.instance.isServiceStarted)
89
+        {
90
+            stopForeground(true)
91
+            stopSelf()
92
+            return START_NOT_STICKY
93
+        }
94
+        when (intent?.getStringExtra("action")) {
95
+            Actions.NOTIFY.name -> {
96
+                val date = Date()   // given date
97
+                val calendar = Calendar.getInstance() // creates a new calendar instance
98
+                calendar.time = date   // assigns calendar to given date
99
+                val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format
100
+                //val hours_american = calendar.get(Calendar.HOUR)        // gets hour in 12h format
101
+                val minutes = calendar.get(Calendar.MINUTE)       // gets month number, NOTE this is zero based!
102
+
103
+                Log.d(tag, "Fetched streamer name at ${hours}:${if (minutes < 10) "0" else ""}${minutes}")
104
+                fetchStreamer(this)
105
+                startNextAlarmStreamer(this) // schedule next alarm
106
+                return START_STICKY
107
+            }
108
+            Actions.KILL.name -> {
109
+                stopForeground(true)
110
+                stopSelf()
111
+                return START_NOT_STICKY
112
+            }
113
+        }
114
+        return START_STICKY
115
+        //super.onStartCommand(intent, flags, startId)
116
+    }
117
+
118
+    override fun onDestroy() {
119
+        WorkerStore.instance.streamerName.removeObserver(streamerNameObserver)
120
+        WorkerStore.instance.isServiceStarted = false
121
+        super.onDestroy()
122
+    }
123
+}

+ 35 - 0
app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerNotification.kt View File

@@ -0,0 +1,35 @@
1
+package io.r_a_d.radio2.streamerNotificationService
2
+
3
+import android.content.Context
4
+import io.r_a_d.radio2.BaseNotification
5
+import java.util.*
6
+
7
+class StreamerNotification(
8
+    notificationChannelId: String,
9
+    notificationChannel : Int,
10
+    notificationId: Int,
11
+    notificationImportance: Int
12
+
13
+) : BaseNotification
14
+    (
15
+    notificationChannelId,
16
+    notificationChannel,
17
+    notificationId,
18
+    notificationImportance
19
+){
20
+    override fun create(c: Context) {
21
+        super.create(c)
22
+        val date = Date()   // given date
23
+        val calendar = Calendar.getInstance() // creates a new calendar instance
24
+        calendar.time = date   // assigns calendar to given date
25
+        val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format
26
+        //val hours_american = calendar.get(Calendar.HOUR)        // gets hour in 12h format
27
+        val minutes = calendar.get(Calendar.MINUTE)       // gets month number, NOTE this is zero based!
28
+
29
+        builder.setContentTitle("${WorkerStore.instance.streamerName.value} started streaming!")
30
+        builder.setContentText("Started at ${hours}:${if (minutes < 10) "0" else ""}${minutes}")
31
+        builder.setAutoCancel(true)
32
+        super.show()
33
+    }
34
+
35
+}

+ 46 - 0
app/src/main/java/io/r_a_d/radio2/streamerNotificationService/WorkerStore.kt View File

@@ -0,0 +1,46 @@
1
+package io.r_a_d.radio2.streamerNotificationService
2
+
3
+import android.content.Context
4
+import android.util.Log
5
+import androidx.core.app.NotificationCompat
6
+import androidx.lifecycle.MutableLiveData
7
+import androidx.lifecycle.Observer
8
+import androidx.preference.PreferenceManager
9
+import io.r_a_d.radio2.R
10
+import io.r_a_d.radio2.tag
11
+
12
+class WorkerStore {
13
+    companion object {
14
+        val instance = WorkerStore()
15
+    }
16
+
17
+    val streamerName = MutableLiveData<String>()
18
+    var isServiceStarted : Boolean = false
19
+    var tickerPeriod : Long = 45 // seconds
20
+
21
+    init {
22
+        tickerPeriod = 45
23
+        streamerName.value = ""
24
+        isServiceStarted = false
25
+    }
26
+
27
+    fun init(c: Context)
28
+    {
29
+        tickerPeriod = 45
30
+        streamerName.value = ""
31
+        val tickerPeriod = 60 *
32
+                (if (PreferenceManager.getDefaultSharedPreferences(c).contains("streamerMonitorPeriodPref"))
33
+                    Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(c).getString("streamerMonitorPeriodPref", "15")!!).toLong()
34
+                else
35
+                    15
36
+                        )
37
+        instance.tickerPeriod = tickerPeriod
38
+        Log.d(tag, "tickerPeriod = $tickerPeriod")
39
+
40
+        with(PreferenceManager.getDefaultSharedPreferences(c).edit()){
41
+            remove("streamerName")
42
+            commit() // I commit on main thread to be sure it's been updated before continuing.
43
+        }
44
+    }
45
+
46
+}

+ 52 - 0
app/src/main/java/io/r_a_d/radio2/ui/chat/ChatFragment.kt View File

@@ -0,0 +1,52 @@
1
+package io.r_a_d.radio2.ui.chat
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import android.view.LayoutInflater
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import android.view.WindowManager
9
+import android.webkit.WebView
10
+import androidx.fragment.app.Fragment
11
+import androidx.lifecycle.ViewModelProviders
12
+import io.r_a_d.radio2.R
13
+
14
+
15
+class ChatFragment : Fragment() {
16
+
17
+    private lateinit var chatViewModel: ChatViewModel
18
+
19
+
20
+    override fun onCreateView(
21
+        inflater: LayoutInflater,
22
+        container: ViewGroup?,
23
+        savedInstanceState: Bundle?
24
+    ): View? {
25
+        activity?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
26
+
27
+        chatViewModel =
28
+                ViewModelProviders.of(this).get(ChatViewModel::class.java)
29
+
30
+        if (!chatViewModel.isChatLoaded)
31
+        {
32
+
33
+            try {
34
+                chatViewModel.root = inflater.inflate(R.layout.fragment_chat, container, false)
35
+                chatViewModel.webView = chatViewModel.root.findViewById<WebView>(R.id.chat_webview)
36
+                chatViewModel.webViewChat = WebViewChat(chatViewModel.webView as WebView)
37
+                chatViewModel.webViewChat!!.start()
38
+            } catch (e: Exception) {
39
+                chatViewModel.root = inflater.inflate(R.layout.fragment_error_chat, container, false)
40
+            }
41
+
42
+            chatViewModel.isChatLoaded = true
43
+            Log.d(tag, "webview created")
44
+        } else {
45
+            Log.d(tag, "webview already created!?")
46
+        }
47
+
48
+        return chatViewModel.root
49
+    }
50
+
51
+
52
+}

+ 12 - 0
app/src/main/java/io/r_a_d/radio2/ui/chat/ChatViewModel.kt View File

@@ -0,0 +1,12 @@
1
+package io.r_a_d.radio2.ui.chat
2
+
3
+import android.view.View
4
+import android.webkit.WebView
5
+import androidx.lifecycle.ViewModel
6
+
7
+class ChatViewModel : ViewModel() {
8
+    lateinit var root: View
9
+    var webView: WebView? = null
10
+    var isChatLoaded = false
11
+    var webViewChat: WebViewChat? = null
12
+}

+ 47 - 0
app/src/main/java/io/r_a_d/radio2/ui/chat/WebViewChat.kt View File

@@ -0,0 +1,47 @@
1
+package io.r_a_d.radio2.ui.chat
2
+
3
+import android.annotation.SuppressLint
4
+import android.content.Intent
5
+import android.net.Uri
6
+import android.webkit.WebChromeClient
7
+import android.webkit.WebView
8
+
9
+class WebViewChat(private val webView: WebView) {
10
+
11
+        @SuppressLint("SetJavaScriptEnabled")
12
+        fun start() {
13
+
14
+            val webSetting = this.webView.settings
15
+            webSetting.javaScriptEnabled = true
16
+            webSetting.setSupportZoom(false)
17
+
18
+            /* TODO: in the future, it could be nice to have a parameters screen where you can:
19
+         - Set the text zoom
20
+         - Set your username (to not type it every time, would it be possible?)
21
+         - Hide the chat?
22
+         - do more? */
23
+            webSetting.textZoom = 90
24
+
25
+            webSetting.setSupportMultipleWindows(true)
26
+            // needs to open target="_blank" links as KiwiIRC links have this attribute.
27
+            // shamelessly ripped off https://stackoverflow.com/questions/18187714/android-open-target-blank-links-in-webview-with-external-browser
28
+            this.webView.webChromeClient = object : WebChromeClient() {
29
+                override fun onCreateWindow(
30
+                    view: WebView,
31
+                    dialog: Boolean,
32
+                    userGesture: Boolean,
33
+                    resultMsg: android.os.Message
34
+                ): Boolean {
35
+                    val result = view.hitTestResult
36
+                    val data = result.extra
37
+                    val context = view.context
38
+                    val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(data))
39
+                    context.startActivity(browserIntent)
40
+                    return false
41
+                }
42
+            }
43
+
44
+            webView.loadUrl("file:///android_asset/chat.html")
45
+        }
46
+
47
+    }

+ 16 - 0
app/src/main/java/io/r_a_d/radio2/ui/news/News.kt View File

@@ -0,0 +1,16 @@
1
+package io.r_a_d.radio2.ui.news
2
+
3
+import java.util.*
4
+
5
+class News {
6
+    var title: String = ""
7
+    var text: String = ""
8
+    var header: String = ""
9
+    var author: String = ""
10
+    var date: Date = Date()
11
+
12
+    override fun toString() : String
13
+    {
14
+        return "$author | $title | $date | $header"
15
+    }
16
+}

+ 67 - 0
app/src/main/java/io/r_a_d/radio2/ui/news/NewsAdapter.kt View File

@@ -0,0 +1,67 @@
1
+package io.r_a_d.radio2.ui.news
2
+
3
+import android.annotation.SuppressLint
4
+import android.widget.TextView
5
+import android.view.LayoutInflater
6
+import android.view.ViewGroup
7
+import androidx.constraintlayout.widget.ConstraintLayout
8
+import androidx.core.text.HtmlCompat
9
+import androidx.core.widget.TextViewCompat
10
+import androidx.recyclerview.widget.RecyclerView
11
+import io.r_a_d.radio2.R
12
+import kotlin.collections.ArrayList
13
+
14
+class NewsAdapter(private val dataSet: ArrayList<News>
15
+    /*,
16
+    context: Context,
17
+    resource: Int,
18
+    objects: Array<out Song>*/
19
+) : RecyclerView.Adapter<NewsAdapter.MyViewHolder>() /*ArrayAdapter<Song>(context, resource, objects)*/ {
20
+
21
+
22
+    // Provide a reference to the views for each data item
23
+    // Complex data items may need more than one view per item, and
24
+    // you provide access to all the views for a data item in a view holder.
25
+    // Each data item is just a string in this case that is shown in a TextView.
26
+    class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view)
27
+
28
+
29
+    // Create new views (invoked by the layout manager)
30
+    override fun onCreateViewHolder(parent: ViewGroup,
31
+                                    viewType: Int): MyViewHolder {
32
+        // create a new view
33
+        val view = LayoutInflater.from(parent.context)
34
+            .inflate(R.layout.news_view, parent, false) as ConstraintLayout
35
+        // set the view's size, margins, paddings and layout parameters
36
+        //...
37
+        return MyViewHolder(view)
38
+    }
39
+
40
+    // Replace the contents of a view (invoked by the layout manager)
41
+    @SuppressLint("SetTextI18n")
42
+    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
43
+        val title = holder.itemView.findViewById<TextView>(R.id.news_title)
44
+        val text = holder.itemView.findViewById<TextView>(R.id.news_text)
45
+        val author = holder.itemView.findViewById<TextView>(R.id.news_author)
46
+        val header = holder.itemView.findViewById<TextView>(R.id.news_header)
47
+        title.text = dataSet[position].title
48
+        text.text = HtmlCompat.fromHtml(dataSet[position].text, HtmlCompat.FROM_HTML_MODE_LEGACY)
49
+        header.text = HtmlCompat.fromHtml(dataSet[position].header, HtmlCompat.FROM_HTML_MODE_LEGACY).replace(Regex("\n"), " ")
50
+        author.text = "| ${dataSet[position].author}"
51
+        TextViewCompat.setAutoSizeTextTypeWithDefaults(author, TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM)
52
+    }
53
+
54
+    // Return the size of your dataset (invoked by the layout manager)
55
+    override fun getItemCount() = dataSet.size
56
+
57
+
58
+    /*
59
+    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
60
+        // create a new view
61
+        val view = LayoutInflater.from(parent.context)
62
+            .inflate(R.layout.song_view, parent, false) as ConstraintLayout
63
+    }
64
+    */
65
+
66
+}
67
+

+ 62 - 0
app/src/main/java/io/r_a_d/radio2/ui/news/NewsFragment.kt View File

@@ -0,0 +1,62 @@
1
+package io.r_a_d.radio2.ui.news
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import android.view.LayoutInflater
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import androidx.fragment.app.Fragment
9
+import androidx.lifecycle.ViewModelProviders
10
+import androidx.recyclerview.widget.LinearLayoutManager
11
+import androidx.recyclerview.widget.RecyclerView
12
+import io.r_a_d.radio2.R
13
+
14
+class NewsFragment : Fragment() {
15
+
16
+    private lateinit var newsViewModel: NewsViewModel
17
+    private lateinit var recyclerView: RecyclerView
18
+    private lateinit var viewAdapter: RecyclerView.Adapter<*>
19
+    private lateinit var viewManager: RecyclerView.LayoutManager
20
+
21
+    override fun onCreateView(
22
+        inflater: LayoutInflater,
23
+        container: ViewGroup?,
24
+        savedInstanceState: Bundle?
25
+    ): View? {
26
+        newsViewModel =
27
+                ViewModelProviders.of(this).get(NewsViewModel::class.java)
28
+
29
+        val root = inflater.inflate(R.layout.fragment_news, container, false) as androidx.swiperefreshlayout.widget.SwipeRefreshLayout
30
+
31
+        viewManager = LinearLayoutManager(context)
32
+        viewAdapter = NewsAdapter(newsViewModel.newsArray)
33
+        recyclerView = root.findViewById<RecyclerView>(R.id.news_recycler).apply {
34
+            // use this setting to improve performance if you know that changes
35
+            // in content do not change the layout size of the RecyclerView
36
+            setHasFixedSize(true)
37
+
38
+            // use a linear layout manager
39
+            layoutManager = viewManager
40
+
41
+            // specify an viewAdapter (see also next example)
42
+            adapter = viewAdapter
43
+        }
44
+
45
+        root.setOnRefreshListener {
46
+
47
+            newsViewModel.fetch(root, viewAdapter)
48
+
49
+        }
50
+
51
+        return root
52
+    }
53
+
54
+    override fun onCreate(savedInstanceState: Bundle?) {
55
+        newsViewModel =
56
+            ViewModelProviders.of(this).get(NewsViewModel::class.java)
57
+
58
+        newsViewModel.fetch()
59
+        Log.d(tag, "news fetched onCreate")
60
+        super.onCreate(savedInstanceState)
61
+    }
62
+}

+ 52 - 0
app/src/main/java/io/r_a_d/radio2/ui/news/NewsViewModel.kt View File

@@ -0,0 +1,52 @@
1
+package io.r_a_d.radio2.ui.news
2
+
3
+import android.util.Log
4
+import androidx.lifecycle.ViewModel
5
+import androidx.recyclerview.widget.RecyclerView
6
+import io.r_a_d.radio2.Async
7
+import io.r_a_d.radio2.tag
8
+import org.json.JSONArray
9
+import org.json.JSONObject
10
+import java.net.URL
11
+import java.text.SimpleDateFormat
12
+import java.util.*
13
+import kotlin.collections.ArrayList
14
+
15
+
16
+class NewsViewModel : ViewModel() {
17
+
18
+    val newsArray : ArrayList<News> = ArrayList()
19
+
20
+    private val urlToScrape = "https://r-a-d.io/api/news"
21
+
22
+    private val scrape : (Any?) -> Unit =
23
+    {
24
+        val t = URL(urlToScrape).readText()
25
+        val result = JSONArray(t)
26
+        newsArray.clear()
27
+        for (n in 0 until result.length())
28
+        {
29
+            val news = News()
30
+            news.title = (result[n] as JSONObject).getString("title")
31
+            news.author = (result[n] as JSONObject).getJSONObject("author").getString("user")
32
+            news.text = (result[n] as JSONObject).getString("text")
33
+            news.header = (result[n] as JSONObject).getString("header")
34
+
35
+            val formatter6 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
36
+
37
+            news.date = formatter6.parse((result[n] as JSONObject).getString("updated_at")) ?: Date()
38
+
39
+            Log.d(tag, "$news")
40
+            newsArray.add(news)
41
+        }
42
+    }
43
+
44
+    fun fetch(root: androidx.swiperefreshlayout.widget.SwipeRefreshLayout? = null, viewAdapter: RecyclerView.Adapter<*>? = null)
45
+    {
46
+        val post : (parameter: Any?) -> Unit = {
47
+            root?.isRefreshing = false
48
+            viewAdapter?.notifyDataSetChanged()
49
+        }
50
+        Async(scrape, post)
51
+    }
52
+}

+ 294 - 0
app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingFragment.kt View File

@@ -0,0 +1,294 @@
1
+package io.r_a_d.radio2.ui.nowplaying
2
+
3
+import android.annotation.SuppressLint
4
+import android.content.ClipboardManager
5
+import android.content.Context
6
+import androidx.lifecycle.ViewModelProviders
7
+import android.os.Bundle
8
+import android.support.v4.media.session.PlaybackStateCompat
9
+import android.util.Log
10
+import android.util.TypedValue
11
+import androidx.fragment.app.Fragment
12
+import android.view.LayoutInflater
13
+import android.view.View
14
+import android.view.ViewGroup
15
+import android.widget.*
16
+import androidx.constraintlayout.widget.ConstraintLayout
17
+import androidx.constraintlayout.widget.ConstraintSet
18
+import androidx.core.widget.TextViewCompat
19
+import androidx.lifecycle.Observer
20
+import com.google.android.material.snackbar.BaseTransientBottomBar
21
+import com.google.android.material.snackbar.Snackbar
22
+import io.r_a_d.radio2.*
23
+import io.r_a_d.radio2.alarm.RadioSleeper
24
+import io.r_a_d.radio2.playerstore.PlayerStore
25
+import io.r_a_d.radio2.playerstore.Song
26
+
27
+
28
+class NowPlayingFragment : Fragment() {
29
+
30
+    private lateinit var root: View
31
+    private lateinit var nowPlayingViewModel: NowPlayingViewModel
32
+
33
+    @SuppressLint("SetTextI18n")
34
+    override fun onCreateView(
35
+        inflater: LayoutInflater,
36
+        container: ViewGroup?,
37
+        savedInstanceState: Bundle?
38
+    ): View? {
39
+
40
+        nowPlayingViewModel = ViewModelProviders.of(this).get(NowPlayingViewModel::class.java)
41
+        root = inflater.inflate(R.layout.fragment_nowplaying, container, false)
42
+
43
+        // View bindings to the ViewModel
44
+        val songTitleText: TextView = root.findViewById(R.id.text_song_title)
45
+        val songArtistText: TextView = root.findViewById(R.id.text_song_artist)
46
+        val seekBarVolume: SeekBar = root.findViewById(R.id.seek_bar_volume)
47
+        val volumeText: TextView = root.findViewById(R.id.volume_text)
48
+        val progressBar: ProgressBar = root.findViewById(R.id.progressBar)
49
+        val streamerPictureImageView: ImageView = root.findViewById(R.id.streamerPicture)
50
+        val streamerNameText : TextView = root.findViewById(R.id.streamerName)
51
+        val songTitleNextText: TextView = root.findViewById(R.id.text_song_title_next)
52
+        val songArtistNextText: TextView = root.findViewById(R.id.text_song_artist_next)
53
+        val volumeIconImage : ImageView = root.findViewById(R.id.volume_icon)
54
+        val listenersText : TextView = root.findViewById(R.id.listenersCount)
55
+
56
+
57
+        TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
58
+            streamerNameText,8, 20, 2, TypedValue.COMPLEX_UNIT_SP)
59
+        TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
60
+            listenersText,8, 16, 2, TypedValue.COMPLEX_UNIT_SP)
61
+        /*
62
+        TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
63
+            songTitleText,4, 24, 2, TypedValue.COMPLEX_UNIT_SP)
64
+        TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
65
+            songArtistText,4, 24, 2, TypedValue.COMPLEX_UNIT_SP)
66
+         */
67
+
68
+        PlayerStore.instance.currentSong.title.observe(viewLifecycleOwner, Observer {
69
+                songTitleText.text = it
70
+        })
71
+
72
+        PlayerStore.instance.currentSong.artist.observe(viewLifecycleOwner, Observer {
73
+                songArtistText.text = it
74
+        })
75
+
76
+        PlayerStore.instance.playbackState.observe(viewLifecycleOwner, Observer {
77
+            syncPlayPauseButtonImage(root)
78
+        })
79
+
80
+        // trick : I can't observe the queue because it's an ArrayDeque that doesn't trigger any change...
81
+        // so I observe a dedicated Mutable that gets set when the queue is updated.
82
+        PlayerStore.instance.isQueueUpdated.observe(viewLifecycleOwner, Observer {
83
+            val t = if (PlayerStore.instance.queue.size > 0) PlayerStore.instance.queue[0] else Song("No queue - ") // (it.peekFirst != null ? it.peekFirst : Song() )
84
+            songTitleNextText.text = t.title.value
85
+            songArtistNextText.text = t.artist.value
86
+        })
87
+
88
+        fun volumeIcon(it: Int)
89
+        {
90
+            volumeText.text = "$it%"
91
+            when {
92
+                it > 66 -> volumeIconImage.setImageResource(R.drawable.ic_volume_high)
93
+                it in 33..66 -> volumeIconImage.setImageResource(R.drawable.ic_volume_medium)
94
+                it in 0..33 -> volumeIconImage.setImageResource(R.drawable.ic_volume_low)
95
+                else -> volumeIconImage.setImageResource(R.drawable.ic_volume_off)
96
+            }
97
+        }
98
+
99
+        PlayerStore.instance.volume.observe(viewLifecycleOwner, Observer {
100
+            volumeIcon(it)
101
+            seekBarVolume.progress = it // this updates the seekbar if it's set by something else when going to sleep.
102
+        })
103
+
104
+        PlayerStore.instance.isMuted.observe(viewLifecycleOwner, Observer {
105
+            if (it)
106
+                volumeIconImage.setImageResource(R.drawable.ic_volume_off)
107
+            else
108
+                volumeIcon(PlayerStore.instance.volume.value!!)
109
+        })
110
+
111
+
112
+        PlayerStore.instance.streamerPicture.observe(viewLifecycleOwner, Observer { pic ->
113
+            streamerPictureImageView.setImageBitmap(pic)
114
+        })
115
+
116
+        PlayerStore.instance.streamerName.observe(viewLifecycleOwner, Observer {
117
+            streamerNameText.text = it
118
+        })
119
+
120
+        PlayerStore.instance.listenersCount.observe(viewLifecycleOwner, Observer {
121
+            listenersText.text = "${getString(R.string.listeners)}: $it"
122
+        })
123
+
124
+        // fuck it, do it on main thread
125
+        PlayerStore.instance.currentTime.observe(viewLifecycleOwner, Observer {
126
+            val dd = (PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!).toInt()
127
+            progressBar.progress = dd
128
+        })
129
+
130
+        PlayerStore.instance.currentSong.stopTime.observe(viewLifecycleOwner, Observer {
131
+            val dd = (PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!).toInt()
132
+            progressBar.max = dd
133
+        })
134
+
135
+        PlayerStore.instance.currentSong.stopTime.observe(viewLifecycleOwner, Observer {
136
+            val t : TextView= root.findViewById(R.id.endTime)
137
+            val minutes: String = ((PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/60/1000).toString()
138
+            val seconds: String = ((PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/1000%60).toString()
139
+            t.text = "$minutes:${if (seconds.toInt() < 10) "0" else ""}$seconds"
140
+        })
141
+
142
+        PlayerStore.instance.currentTime.observe(viewLifecycleOwner, Observer {
143
+            val t : TextView= root.findViewById(R.id.currentTime)
144
+            val minutes: String = ((PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/60/1000).toString()
145
+            val seconds: String = ((PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/1000%60).toString()
146
+            t.text = "$minutes:${if (seconds.toInt() < 10) "0" else ""}$seconds"
147
+
148
+            val sleepInfoText = root.findViewById<TextView>(R.id.sleepInfo)
149
+            val sleepAtMillis = RadioSleeper.instance.sleepAtMillis.value
150
+            if (sleepAtMillis != null)
151
+            {
152
+                val duration = ((sleepAtMillis - System.currentTimeMillis()).toFloat() / (60f * 1000f) + 1).toInt() // I put 1 + it because the division rounds to the lower integer. I'd like to display the round up, like it's usually done.
153
+                sleepInfoText.text = "Will close in $duration minute${if (duration > 1) "s" else ""}"
154
+                sleepInfoText.visibility = View.VISIBLE
155
+            } else {
156
+                sleepInfoText.visibility = View.GONE
157
+            }
158
+        })
159
+
160
+
161
+        seekBarVolume.setOnSeekBarChangeListener(nowPlayingViewModel.seekBarChangeListener)
162
+        seekBarVolume.progress = PlayerStore.instance.volume.value!!
163
+        progressBar.max = 1000
164
+        progressBar.progress = 0
165
+
166
+        syncPlayPauseButtonImage(root)
167
+
168
+        // initialize the value for isPlaying when displaying the fragment
169
+        PlayerStore.instance.isPlaying.value = PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING
170
+
171
+        val button: ImageButton = root.findViewById(R.id.play_pause)
172
+        button.setOnClickListener{
173
+            PlayerStore.instance.isPlaying.value = PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED
174
+        }
175
+
176
+        /*
177
+            /* TODO : disabled volumeIconImage click listener, it creates weird behaviors when switching fragments.
178
+                in particular, the mute state isn't retained when switching fragments, and it creates visual error
179
+                (displaying the mute icon when it's not muted).
180
+                So for the moment it's safer to disable it altogether.
181
+             */
182
+        volumeIconImage.setOnClickListener{
183
+            PlayerStore.instance.isMuted.value = !PlayerStore.instance.isMuted.value!!
184
+        }
185
+
186
+         */
187
+
188
+        val setClipboardListener: View.OnLongClickListener = View.OnLongClickListener {
189
+            val text = PlayerStore.instance.currentSong.artist.value + " - " + PlayerStore.instance.currentSong.title.value
190
+            val clipboard = context!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
191
+            val clip = android.content.ClipData.newPlainText("Copied Text", text)
192
+            clipboard.setPrimaryClip(clip)
193
+            val snackBarLength = if (preferenceStore.getBoolean("snackbarPersistent", true))
194
+                Snackbar.LENGTH_INDEFINITE
195
+            else Snackbar.LENGTH_LONG
196
+            val snackBar = Snackbar.make(it, "", snackBarLength)
197
+
198
+            if (snackBarLength == Snackbar.LENGTH_INDEFINITE)
199
+                snackBar.setAction("OK") { snackBar.dismiss() }
200
+
201
+            snackBar.behavior = BaseTransientBottomBar.Behavior().apply {
202
+                setSwipeDirection(BaseTransientBottomBar.Behavior.SWIPE_DIRECTION_ANY)
203
+            }
204
+            snackBar.setText(getString(R.string.song_to_clipboard))
205
+            snackBar.show()
206
+            true
207
+        }
208
+
209
+        songTitleText.setOnLongClickListener(setClipboardListener)
210
+        songArtistText.setOnLongClickListener(setClipboardListener)
211
+
212
+        if (preferenceStore.getBoolean("splitLayout", true))
213
+            root.addOnLayoutChangeListener(splitLayoutListener)
214
+
215
+        return root
216
+    }
217
+
218
+    private val splitLayoutListener : View.OnLayoutChangeListener = View.OnLayoutChangeListener { _: View, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int ->
219
+
220
+        val isSplitLayout = preferenceStore.getBoolean("splitLayout", true)
221
+
222
+        val viewHeight = (root.rootView?.height ?: 1)
223
+        val viewWidth = (root.rootView?.width ?: 1)
224
+
225
+        val newRatio = if (viewWidth > 0)
226
+            (viewHeight*100)/viewWidth
227
+        else
228
+            100
229
+
230
+        if (isSplitLayout && nowPlayingViewModel.screenRatio != newRatio) {
231
+            onOrientation()
232
+        }
233
+    }
234
+
235
+    override fun onViewStateRestored(savedInstanceState: Bundle?) {
236
+        onOrientation()
237
+        super.onViewStateRestored(savedInstanceState)
238
+    }
239
+
240
+    private fun onOrientation() {
241
+        val viewHeight = (root.rootView?.height ?: 1)
242
+        val viewWidth = (root.rootView?.width ?: 1)
243
+
244
+        val isSplitLayout = preferenceStore.getBoolean("splitLayout", true)
245
+
246
+        // modify layout to adapt for portrait/landscape
247
+        val isLandscape = viewHeight.toDouble()/viewWidth.toDouble() < 1
248
+        val parentLayout = root.findViewById<ConstraintLayout>(R.id.parentNowPlaying)
249
+        val constraintSet = ConstraintSet()
250
+        constraintSet.clone(parentLayout)
251
+
252
+        if (isLandscape && isSplitLayout)
253
+        {
254
+            constraintSet.connect(R.id.layoutBlock1, ConstraintSet.BOTTOM, R.id.parentNowPlaying, ConstraintSet.BOTTOM)
255
+            constraintSet.connect(R.id.layoutBlock1, ConstraintSet.END, R.id.splitHorizontalLayout, ConstraintSet.END)
256
+            constraintSet.connect(R.id.layoutBlock2, ConstraintSet.TOP, R.id.parentNowPlaying, ConstraintSet.TOP)
257
+            constraintSet.connect(R.id.layoutBlock2, ConstraintSet.START, R.id.splitHorizontalLayout, ConstraintSet.END)
258
+            constraintSet.setMargin(R.id.layoutBlock1, ConstraintSet.END, 16)
259
+            constraintSet.setMargin(R.id.layoutBlock2, ConstraintSet.START, 16)
260
+        } else {
261
+            constraintSet.connect(R.id.layoutBlock1, ConstraintSet.BOTTOM, R.id.splitVerticalLayout, ConstraintSet.BOTTOM)
262
+            constraintSet.connect(R.id.layoutBlock1, ConstraintSet.END, R.id.parentNowPlaying, ConstraintSet.END)
263
+            constraintSet.connect(R.id.layoutBlock2, ConstraintSet.TOP, R.id.splitVerticalLayout, ConstraintSet.BOTTOM)
264
+            constraintSet.connect(R.id.layoutBlock2, ConstraintSet.START, R.id.parentNowPlaying, ConstraintSet.START)
265
+            constraintSet.setMargin(R.id.layoutBlock1, ConstraintSet.END, 0)
266
+            constraintSet.setMargin(R.id.layoutBlock2, ConstraintSet.START, 0)
267
+        }
268
+        constraintSet.applyTo(parentLayout)
269
+
270
+        // note : we have to COMPARE numbers that are FRACTIONS. And everyone knows that we should NEVER compare DOUBLES because of the imprecision at the end.
271
+        // So instead, I multiply the result by 100 (to give 2 significant numbers), and do an INTEGER DIVISION. This is the right way to compare ratios.
272
+        nowPlayingViewModel.screenRatio = if (viewWidth > 0)
273
+                (viewHeight*100)/viewWidth
274
+        else
275
+            100
276
+        Log.d(tag, "orientation set")
277
+    }
278
+
279
+    override fun onResume() {
280
+        super.onResume()
281
+        onOrientation()
282
+    }
283
+
284
+    private fun syncPlayPauseButtonImage(v: View)
285
+    {
286
+        val img = v.findViewById<ImageButton>(R.id.play_pause)
287
+
288
+        if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED) {
289
+            img.setImageResource(R.drawable.exo_controls_play)
290
+        } else {
291
+            img.setImageResource(R.drawable.exo_controls_pause)
292
+        }
293
+    }
294
+}

+ 34 - 0
app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingViewModel.kt View File

@@ -0,0 +1,34 @@
1
+package io.r_a_d.radio2.ui.nowplaying
2
+
3
+import android.widget.SeekBar
4
+import androidx.lifecycle.MutableLiveData
5
+import androidx.lifecycle.ViewModel
6
+import io.r_a_d.radio2.playerstore.PlayerStore
7
+
8
+class NowPlayingViewModel: ViewModel() {
9
+
10
+    /* Note : ViewModels do not have any kind of data persistence, which is a bit of a shame.
11
+       Data persistence is currently in beta, and poorly documented (some pages don't even match!)
12
+       For the moment, we will store data related to playback state in PlayerStore.
13
+    */
14
+    var screenRatio: Int = 100
15
+
16
+
17
+
18
+    var seekBarChangeListener: SeekBar.OnSeekBarChangeListener =
19
+        object : SeekBar.OnSeekBarChangeListener {
20
+
21
+            override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
22
+                // updated continuously as the user slides the thumb
23
+                PlayerStore.instance.volume.value = progress
24
+            }
25
+
26
+            override fun onStartTrackingTouch(seekBar: SeekBar) {
27
+                // called when the user first touches the SeekBar
28
+            }
29
+
30
+            override fun onStopTrackingTouch(seekBar: SeekBar) {
31
+                // called after the user finishes moving the SeekBar
32
+            }
33
+        }
34
+}

+ 94 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/SongsFragment.kt View File

@@ -0,0 +1,94 @@
1
+package io.r_a_d.radio2.ui.songs
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import android.view.LayoutInflater
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import android.widget.TextView
9
+import androidx.fragment.app.Fragment
10
+import androidx.fragment.app.FragmentPagerAdapter
11
+import androidx.lifecycle.Observer
12
+import androidx.lifecycle.ViewModelProviders
13
+import androidx.viewpager.widget.ViewPager
14
+import com.google.android.material.snackbar.BaseTransientBottomBar
15
+import com.google.android.material.snackbar.Snackbar
16
+import com.google.android.material.tabs.TabLayout
17
+import io.r_a_d.radio2.R
18
+import io.r_a_d.radio2.preferenceStore
19
+import io.r_a_d.radio2.ui.songs.queuelp.LastPlayedFragment
20
+import io.r_a_d.radio2.ui.songs.queuelp.QueueFragment
21
+import io.r_a_d.radio2.ui.songs.request.FavoritesFragment
22
+import io.r_a_d.radio2.ui.songs.request.RequestFragment
23
+import io.r_a_d.radio2.ui.songs.request.Requestor
24
+
25
+class SongsFragment : Fragment() {
26
+
27
+    private lateinit var adapter : SongsPagerAdapter
28
+    private lateinit var snackBar : Snackbar
29
+    private lateinit var root: View
30
+    private lateinit var viewPager: ViewPager
31
+
32
+
33
+    private val snackBarTextObserver: Observer<String?> = Observer {
34
+        if (Requestor.instance.snackBarText.value != "")
35
+        {
36
+            val snackBarLength = if (preferenceStore.getBoolean("snackbarPersistent", true))
37
+                Snackbar.LENGTH_INDEFINITE
38
+                else Snackbar.LENGTH_LONG
39
+            snackBar = Snackbar.make(viewPager, "", snackBarLength)
40
+
41
+            if (snackBarLength == Snackbar.LENGTH_INDEFINITE)
42
+            snackBar.setAction("OK") {
43
+                snackBar.dismiss()
44
+            }
45
+
46
+            snackBar.behavior = BaseTransientBottomBar.Behavior().apply {
47
+                setSwipeDirection(BaseTransientBottomBar.Behavior.SWIPE_DIRECTION_ANY)
48
+            }
49
+
50
+            val snackBarView = snackBar.view
51
+            val textView =
52
+                snackBarView.findViewById(com.google.android.material.R.id.snackbar_text) as TextView
53
+            if (Requestor.instance.addRequestMeta != "")
54
+                textView.maxLines = 4
55
+            else
56
+                textView.maxLines = 2
57
+            snackBar.setText((it as CharSequence))
58
+            snackBar.show()
59
+            Requestor.instance.snackBarText.value = "" // resetting afterwards to avoid re-triggering it when we enter again the fragment
60
+            Requestor.instance.addRequestMeta = ""
61
+        }
62
+    }
63
+
64
+    override fun onCreateView(
65
+        inflater: LayoutInflater,
66
+        container: ViewGroup?,
67
+        savedInstanceState: Bundle?
68
+    ): View? {
69
+
70
+        root = inflater.inflate(R.layout.fragment_songs, container, false)
71
+        viewPager = root.findViewById(R.id.tabPager)
72
+        adapter = SongsPagerAdapter(childFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT)
73
+        adapter.addFragment(LastPlayedFragment.newInstance(), "last played")
74
+        adapter.addFragment(QueueFragment.newInstance(), "queue")
75
+        adapter.addFragment(RequestFragment.newInstance(), "request")
76
+        adapter.addFragment(FavoritesFragment.newInstance(), "Favorites")
77
+
78
+        viewPager.adapter = adapter
79
+
80
+        val tabLayout : TabLayout = root.findViewById(R.id.tabLayout)
81
+        tabLayout.setupWithViewPager(viewPager)
82
+        Log.d(tag, "SongFragment view created")
83
+
84
+        Requestor.instance.snackBarText.observeForever(snackBarTextObserver)
85
+
86
+        return root
87
+    }
88
+
89
+    override fun onDestroyView() {
90
+        Requestor.instance.snackBarText.removeObserver(snackBarTextObserver)
91
+        super.onDestroyView()
92
+    }
93
+
94
+}

+ 29 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/SongsPagerAdapter.kt View File

@@ -0,0 +1,29 @@
1
+package io.r_a_d.radio2.ui.songs
2
+
3
+import androidx.fragment.app.Fragment
4
+import androidx.fragment.app.FragmentPagerAdapter
5
+import androidx.fragment.app.FragmentManager
6
+
7
+
8
+class SongsPagerAdapter(f: FragmentManager, t: Int) : FragmentPagerAdapter(f, t){
9
+
10
+    private val fragmentList = ArrayList<Fragment>()
11
+    private val fragmentTitleList = ArrayList<String>()
12
+
13
+    override fun getItem(position: Int): Fragment {
14
+        return fragmentList[position]
15
+    }
16
+
17
+    override fun getCount(): Int {
18
+        return fragmentList.size
19
+    }
20
+
21
+    fun addFragment(fragment: Fragment, title: String) {
22
+        fragmentList.add(fragment)
23
+        fragmentTitleList.add(title)
24
+    }
25
+
26
+    override fun getPageTitle(position: Int): CharSequence? {
27
+        return fragmentTitleList[position]
28
+    }
29
+}

+ 86 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/LastPlayedFragment.kt View File

@@ -0,0 +1,86 @@
1
+package io.r_a_d.radio2.ui.songs.queuelp
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import androidx.fragment.app.Fragment
6
+import android.view.LayoutInflater
7
+import android.view.View
8
+import android.view.ViewGroup
9
+import androidx.lifecycle.Observer
10
+import androidx.recyclerview.widget.LinearLayoutManager
11
+import androidx.recyclerview.widget.RecyclerView
12
+import io.r_a_d.radio2.R
13
+import io.r_a_d.radio2.playerstore.PlayerStore
14
+
15
+/**
16
+ * A simple [Fragment] subclass.
17
+ * Activities that contain this fragment must implement the
18
+ * [LastPlayedFragment.OnFragmentInteractionListener] interface
19
+ * to handle interaction events.
20
+ * Use the [LastPlayedFragment.newInstance] factory method to
21
+ * create an instance of this fragment.
22
+ */
23
+
24
+class LastPlayedFragment : Fragment() {
25
+
26
+    private val lastPlayedFragmentTag = this::class.java.name
27
+
28
+    private lateinit var recyclerView: RecyclerView
29
+    private lateinit var viewAdapter: RecyclerView.Adapter<*>
30
+    private lateinit var viewManager: RecyclerView.LayoutManager
31
+
32
+
33
+    private val queueObserver = Observer<Boolean> {
34
+        Log.d(tag, lastPlayedFragmentTag + "queue changed")
35
+        viewAdapter.notifyDataSetChanged()
36
+    }
37
+
38
+    override fun onCreateView(
39
+        inflater: LayoutInflater, container: ViewGroup?,
40
+        savedInstanceState: Bundle?
41
+    ): View? {
42
+        // Inflate the layout for this fragment
43
+        val root = inflater.inflate(R.layout.fragment_last_played, container, false)
44
+
45
+        viewManager = LinearLayoutManager(context)
46
+        viewAdapter = SongAdaptater(PlayerStore.instance.lp)
47
+
48
+        recyclerView = root.findViewById<RecyclerView>(R.id.queue_lp_recycler).apply {
49
+            // use this setting to improve performance if you know that changes
50
+            // in content do not change the layout size of the RecyclerView
51
+            setHasFixedSize(true)
52
+
53
+            // use a linear layout manager
54
+            layoutManager = viewManager
55
+
56
+            // specify an viewAdapter (see also next example)
57
+            adapter = viewAdapter
58
+        }
59
+
60
+        PlayerStore.instance.isLpUpdated.observeForever(queueObserver)
61
+
62
+        return root
63
+    }
64
+
65
+    override fun onDestroyView() {
66
+        PlayerStore.instance.isLpUpdated.removeObserver(queueObserver)
67
+        super.onDestroyView()
68
+    }
69
+    /**
70
+     * This interface must be implemented by activities that contain this
71
+     * fragment to allow an interaction in this fragment to be communicated
72
+     * to the activity and potentially other fragments contained in that
73
+     * activity.
74
+     *
75
+     *
76
+     * See the Android Training lesson [Communicating with Other Fragments]
77
+     * (http://developer.android.com/training/basics/fragments/communicating.html)
78
+     * for more information.
79
+     */
80
+    interface OnFragmentInteractionListener
81
+
82
+    companion object {
83
+        @JvmStatic
84
+        fun newInstance() = LastPlayedFragment()
85
+    }
86
+}

+ 70 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/QueueFragment.kt View File

@@ -0,0 +1,70 @@
1
+package io.r_a_d.radio2.ui.songs.queuelp
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import android.view.LayoutInflater
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import androidx.fragment.app.Fragment
9
+import androidx.lifecycle.Observer
10
+import androidx.recyclerview.widget.LinearLayoutManager
11
+import androidx.recyclerview.widget.RecyclerView
12
+import io.r_a_d.radio2.R
13
+import io.r_a_d.radio2.playerstore.PlayerStore
14
+import io.r_a_d.radio2.playerstore.Song
15
+
16
+class QueueFragment : Fragment(){
17
+    private val lastPlayedFragmentTag = this::class.java.name
18
+
19
+    private lateinit var recyclerView: RecyclerView
20
+    private lateinit var viewAdapter: RecyclerView.Adapter<*>
21
+    private lateinit var viewManager: RecyclerView.LayoutManager
22
+
23
+
24
+    private val queueObserver = Observer<Boolean> {
25
+        Log.d(tag, lastPlayedFragmentTag + "queue changed")
26
+        viewAdapter.notifyDataSetChanged()
27
+    }
28
+
29
+    override fun onCreateView(
30
+        inflater: LayoutInflater, container: ViewGroup?,
31
+        savedInstanceState: Bundle?
32
+    ): View? {
33
+        // Inflate the layout for this fragment
34
+        val root = inflater.inflate(R.layout.fragment_last_played, container, false)
35
+
36
+        viewManager = LinearLayoutManager(context)
37
+        viewAdapter = SongAdaptater(
38
+            if (PlayerStore.instance.queue.isEmpty())
39
+                ArrayList<Song>(listOf((Song("No queue - "))))
40
+            else
41
+                PlayerStore.instance.queue
42
+        )
43
+
44
+        recyclerView = root.findViewById<RecyclerView>(R.id.queue_lp_recycler).apply {
45
+            // use this setting to improve performance if you know that changes
46
+            // in content do not change the layout size of the RecyclerView
47
+            setHasFixedSize(true)
48
+
49
+            // use a linear layout manager
50
+            layoutManager = viewManager
51
+
52
+            // specify an viewAdapter (see also next example)
53
+            adapter = viewAdapter
54
+        }
55
+
56
+        PlayerStore.instance.isQueueUpdated.observeForever(queueObserver)
57
+
58
+        return root
59
+    }
60
+
61
+    override fun onDestroyView() {
62
+        PlayerStore.instance.isQueueUpdated.removeObserver(queueObserver)
63
+        super.onDestroyView()
64
+    }
65
+
66
+    companion object {
67
+        @JvmStatic
68
+        fun newInstance() = QueueFragment()
69
+    }
70
+}

+ 66 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/SongAdaptater.kt View File

@@ -0,0 +1,66 @@
1
+package io.r_a_d.radio2.ui.songs.queuelp
2
+
3
+import android.annotation.SuppressLint
4
+import android.graphics.Color
5
+import android.view.LayoutInflater
6
+import android.view.ViewGroup
7
+import androidx.constraintlayout.widget.ConstraintLayout
8
+import androidx.core.content.res.ResourcesCompat
9
+import androidx.recyclerview.widget.RecyclerView
10
+import io.r_a_d.radio2.R
11
+import io.r_a_d.radio2.colorBlue
12
+import io.r_a_d.radio2.colorWhited
13
+import io.r_a_d.radio2.playerstore.Song
14
+import kotlinx.android.synthetic.main.song_view.view.*
15
+import kotlin.collections.ArrayList
16
+
17
+class SongAdaptater(private val dataSet: ArrayList<Song>
18
+                    /*,
19
+                    context: Context,
20
+                    resource: Int,
21
+                    objects: Array<out Song>*/
22
+) : RecyclerView.Adapter<SongAdaptater.MyViewHolder>() /*ArrayAdapter<Song>(context, resource, objects)*/ {
23
+
24
+
25
+    // Provide a reference to the views for each data item
26
+    // Complex data items may need more than one view per item, and
27
+    // you provide access to all the views for a data item in a view holder.
28
+    // Each data item is just a string in this case that is shown in a TextView.
29
+    class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view)
30
+
31
+
32
+    // Create new views (invoked by the layout manager)
33
+    override fun onCreateViewHolder(parent: ViewGroup,
34
+                                    viewType: Int): MyViewHolder {
35
+        // create a new view
36
+        val view = LayoutInflater.from(parent.context)
37
+            .inflate(R.layout.song_view, parent, false) as ConstraintLayout
38
+        // set the view's size, margins, paddings and layout parameters
39
+        //...
40
+        return MyViewHolder(view)
41
+    }
42
+
43
+    // Replace the contents of a view (invoked by the layout manager)
44
+    @SuppressLint("SetTextI18n")
45
+    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
46
+        holder.itemView.item.text = "${dataSet[position].artist.value} - ${dataSet[position].title.value}"
47
+        if (dataSet[position].type.value == 1)
48
+            holder.itemView.item.setTextColor(colorBlue)
49
+        else
50
+            holder.itemView.item.setTextColor(colorWhited)
51
+    }
52
+
53
+    // Return the size of your dataset (invoked by the layout manager)
54
+    override fun getItemCount() = dataSet.size
55
+
56
+
57
+    /*
58
+    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
59
+        // create a new view
60
+        val view = LayoutInflater.from(parent.context)
61
+            .inflate(R.layout.song_view, parent, false) as ConstraintLayout
62
+    }
63
+    */
64
+
65
+}
66
+

+ 69 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/CooldownCalculator.kt View File

@@ -0,0 +1,69 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import kotlin.math.exp
4
+import kotlin.math.max
5
+
6
+/*
7
+//PHP cooldown calculator
8
+
9
+function pretty_cooldown($lp, $lr, $rc) {
10
+	$delay = delay($rc);
11
+	$now = time();
12
+	$cd = intval(max($lp + $delay - $now, $lr + $delay - $now));
13
+	if ($cd <= 0)
14
+		return "Request";
15
+	$days = intdiv_1($cd, 86400);
16
+	$cd = $cd % 86400;
17
+	$hours = intdiv_1($cd, 3600);
18
+	$cd = $cd % 3600;
19
+	$minutes = intdiv_1($cd, 60);
20
+	$seconds = $cd % 60;
21
+	if ($days > 0)
22
+		return "Requestable in ".$days."d".$hours."h";
23
+	else if ($hours > 0)
24
+		return "Requestable in ".$hours."h".$minutes."m";
25
+	else if ($minutes > 0)
26
+		return "Requestable in ".$minutes."m".$seconds."s";
27
+	return "Request";
28
+}
29
+function requestable($lastplayed, $requests) {
30
+	$delay = delay($requests);
31
+	return (time() - $lastplayed) > $delay;
32
+}
33
+function delay($priority) {
34
+	// priority is 30 max
35
+		if ($priority > 30)
36
+			$priority = 30;
37
+		// between 0 and 7 return magic
38
+		if ($priority >= 0 and $priority <= 7)
39
+			$cd = -11057 * $priority * $priority + 172954 * $priority + 81720;
40
+		// if above that, return magic crazy numbers
41
+		else
42
+			$cd = (int) (599955 * exp(0.0372 * $priority) + 0.5);
43
+		return $cd / 2;
44
+}
45
+ */
46
+
47
+// this function implements the magic delay used on R/a/dio website:
48
+// https://github.com/R-a-dio/site/blob/develop/app/start/global.php#L125
49
+// (Seriously guys, what were you thinking with these crazy magic numbers...)
50
+fun delay(rawPriority: Int) : Int {
51
+    val priority = if (rawPriority > 30) 30 else rawPriority
52
+    val coolDown : Int =
53
+        if (priority in 0..7)
54
+            -11057 * priority * priority + 172954 * priority + 81720
55
+        else
56
+            (599955 * exp(0.0372 * (priority.toDouble()) + 0.5)).toInt()
57
+    return coolDown/2
58
+}
59
+
60
+// I tweaked this to report in a single point whether the song is requestable or not
61
+fun coolDown(lastPlayed: Int?, lastRequest: Int?, requestsNbr: Int?) : Long {
62
+    if (requestsNbr == null || lastPlayed == null || lastRequest == null)
63
+        return Long.MAX_VALUE // maximum positive value : the song won't be requestable
64
+
65
+    val delay = delay(requestsNbr)
66
+    val now = (System.currentTimeMillis() / 1000)
67
+    return max(lastPlayed, lastRequest) + delay - now
68
+    // if coolDown < 0, the song is requestable.
69
+}

+ 132 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/FavoritesFragment.kt View File

@@ -0,0 +1,132 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import android.os.Build
4
+import android.os.Bundle
5
+import android.util.Log
6
+import android.view.LayoutInflater
7
+import android.view.View
8
+import android.view.ViewGroup
9
+import android.widget.SearchView
10
+import android.widget.TextView
11
+import androidx.appcompat.widget.AppCompatButton
12
+import androidx.fragment.app.Fragment
13
+import androidx.lifecycle.Observer
14
+import androidx.recyclerview.widget.LinearLayoutManager
15
+import androidx.recyclerview.widget.RecyclerView
16
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
17
+import io.r_a_d.radio2.*
18
+
19
+
20
+class FavoritesFragment : Fragment()  {
21
+
22
+    private lateinit var recyclerView: RecyclerView
23
+    private lateinit var viewAdapter: RecyclerView.Adapter<*>
24
+    private lateinit var viewManager: RecyclerView.LayoutManager
25
+    private lateinit var searchView: SearchView
26
+    private lateinit var root: View
27
+    private lateinit var recyclerSwipe: SwipeRefreshLayout
28
+
29
+    private val favoritesSongObserver : Observer<Boolean> = Observer {
30
+        viewAdapter.notifyDataSetChanged()
31
+        createView(isCallback = true) // force-re-create the view, but do not call again the initFavorites (avoid callback loop)
32
+        recyclerSwipe.isRefreshing = false // disable refreshing animation. Needs to be done manually...
33
+    }
34
+
35
+    override fun onCreateView(
36
+        inflater: LayoutInflater,
37
+        container: ViewGroup?,
38
+        savedInstanceState: Bundle?
39
+    ): View? {
40
+        super.onCreateView(inflater, container, savedInstanceState)
41
+        root = inflater.inflate(R.layout.fragment_request, container, false)
42
+
43
+        return createView()
44
+    }
45
+
46
+    private fun createView(isCallback: Boolean = false) : View?
47
+    {
48
+
49
+        viewAdapter = RequestSongAdapter(Requestor.instance.favoritesSongArray)
50
+
51
+        val listener : SearchView.OnQueryTextListener = object : SearchView.OnQueryTextListener{
52
+            override fun onQueryTextSubmit(query: String?): Boolean {
53
+                // do nothing
54
+                return true
55
+            }
56
+            override fun onQueryTextChange(newText: String?): Boolean {
57
+                (viewAdapter as RequestSongAdapter).filter(newText ?: "")
58
+                return true
59
+            }
60
+        }
61
+
62
+        searchView = root.findViewById(R.id.searchBox)
63
+        searchView.queryHint = "Search filter..."
64
+        searchView.setOnQueryTextListener(listener)
65
+        viewManager = LinearLayoutManager(context)
66
+        recyclerView = root.findViewById<RecyclerView>(R.id.request_recycler).apply {
67
+            // use this setting to improve performance if you know that changes
68
+            // in content do not change the layout size of the RecyclerView
69
+            setHasFixedSize(true)
70
+
71
+            // use a linear layout manager
72
+            layoutManager = viewManager
73
+
74
+            // specify an viewAdapter (see also next example)
75
+            adapter = viewAdapter
76
+        }
77
+
78
+        val noUserNameText : TextView = root.findViewById(R.id.noUserNameText)
79
+
80
+        recyclerSwipe = root.findViewById(R.id.recyclerSwipe) as SwipeRefreshLayout
81
+        recyclerSwipe.setOnRefreshListener {
82
+            val userName1 = preferenceStore.getString("userName", null)
83
+            Log.d(tag,"userName = $userName1")
84
+            if (userName1 != null && !userName1.isBlank())
85
+            {
86
+                noUserNameText.visibility = View.GONE
87
+                Requestor.instance.initFavorites()
88
+            } else {
89
+                noUserNameText.visibility = View.VISIBLE
90
+                recyclerSwipe.isRefreshing = false
91
+            }
92
+        }
93
+
94
+
95
+        val userName1 = preferenceStore.getString("userName", null)
96
+        Log.d(tag,"userName = $userName1")
97
+        if (userName1 != null && !userName1.isBlank())
98
+        {
99
+            noUserNameText.visibility = View.GONE
100
+            if (!isCallback) // avoid callback loop if called from the Observer.
101
+                Requestor.instance.initFavorites()
102
+        } else {
103
+            noUserNameText.visibility = View.VISIBLE
104
+            recyclerSwipe.isRefreshing = false
105
+        }
106
+
107
+        val raFButton : AppCompatButton = root.findViewById(R.id.ra_f_button)
108
+
109
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) // for API21+ Material Design makes ripples on the button.
110
+            raFButton.supportBackgroundTintList = colorGreenList
111
+        else // But on API20- no Material Design support, so we add some more color when clicked
112
+            raFButton.supportBackgroundTintList = colorGreenListCompat
113
+        raFButton.isEnabled = true
114
+        raFButton.isClickable = true
115
+        raFButton.setOnClickListener {
116
+            val s  = Requestor.instance.raF()
117
+            Requestor.instance.snackBarText.value = ""
118
+            Requestor.instance.addRequestMeta = "Request: ${s.artist.value} - ${s.title.value}\n"
119
+            Requestor.instance.request(s.id)
120
+        }
121
+        raFButton.visibility = View.VISIBLE
122
+
123
+        Requestor.instance.isFavoritesUpdated.observe(viewLifecycleOwner, favoritesSongObserver)
124
+        return root
125
+    }
126
+
127
+
128
+    companion object {
129
+        @JvmStatic
130
+        fun newInstance() = FavoritesFragment()
131
+    }
132
+}

+ 81 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestFragment.kt View File

@@ -0,0 +1,81 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import android.os.Bundle
4
+import android.util.Log
5
+import android.view.LayoutInflater
6
+import android.view.View
7
+import android.view.ViewGroup
8
+import android.widget.SearchView
9
+import androidx.fragment.app.Fragment
10
+import androidx.lifecycle.Observer
11
+import androidx.recyclerview.widget.LinearLayoutManager
12
+import androidx.recyclerview.widget.RecyclerView
13
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
14
+import io.r_a_d.radio2.R
15
+
16
+class RequestFragment : Fragment() {
17
+
18
+    private lateinit var recyclerView: RecyclerView
19
+    private lateinit var viewAdapter: RecyclerView.Adapter<*>
20
+    private lateinit var viewManager: RecyclerView.LayoutManager
21
+    private lateinit var searchView: SearchView
22
+
23
+    private val listener : SearchView.OnQueryTextListener = object : SearchView.OnQueryTextListener{
24
+        override fun onQueryTextSubmit(query: String?): Boolean {
25
+            if (query == null || query.isEmpty())
26
+                Requestor.instance.snackBarText.value = "Field is empty, no search possible."
27
+            else
28
+                Requestor.instance.search(query)
29
+            return true
30
+        }
31
+        override fun onQueryTextChange(newText: String?): Boolean {
32
+            if (newText == "")
33
+            {
34
+                Requestor.instance.reset()
35
+                viewAdapter.notifyDataSetChanged() // this is to remove the "Load more" button
36
+            }
37
+            return true
38
+        }
39
+    }
40
+
41
+    private val requestSongObserver = Observer<Boolean> {
42
+        Log.d(tag, "request song list changed")
43
+        viewAdapter.notifyDataSetChanged()
44
+    }
45
+
46
+    override fun onCreateView(
47
+        inflater: LayoutInflater, container: ViewGroup?,
48
+        savedInstanceState: Bundle?
49
+    ): View? {
50
+        // Inflate the layout for this fragment
51
+        val root = inflater.inflate(R.layout.fragment_request, container, false)
52
+
53
+        val recyclerSwipe = root.findViewById(R.id.recyclerSwipe) as SwipeRefreshLayout
54
+        recyclerSwipe.isEnabled = false // don't need to pull-to-refresh for Request
55
+
56
+        searchView = root.findViewById(R.id.searchBox)
57
+        searchView.setOnQueryTextListener(listener)
58
+
59
+        viewManager = LinearLayoutManager(context)
60
+        viewAdapter = RequestSongAdapter(Requestor.instance.requestSongArray)
61
+
62
+        recyclerView = root.findViewById<RecyclerView>(R.id.request_recycler).apply {
63
+            // use this setting to improve performance if you know that changes
64
+            // in content do not change the layout size of the RecyclerView
65
+            setHasFixedSize(true)
66
+
67
+            // use a linear layout manager
68
+            layoutManager = viewManager
69
+
70
+            // specify an viewAdapter (see also next example)
71
+            adapter = viewAdapter
72
+        }
73
+        Requestor.instance.isRequestResultUpdated.observe(viewLifecycleOwner, requestSongObserver)
74
+        return root
75
+    }
76
+
77
+    companion object {
78
+        @JvmStatic
79
+        fun newInstance() = RequestFragment()
80
+    }
81
+}

+ 42 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestResponse.kt View File

@@ -0,0 +1,42 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import io.r_a_d.radio2.playerstore.Song
4
+import org.json.JSONObject
5
+
6
+class RequestResponse(jsonResponse: JSONObject) {
7
+    //val total: Int = jsonResponse.getInt("total")
8
+    //val perPage: Int = jsonResponse.getInt("per_page") // should stay 20 but in any case...
9
+    val currentPage: Int = jsonResponse.getInt("current_page")
10
+    val lastPage: Int = jsonResponse.getInt("last_page")
11
+    //val fromNbr: Int = jsonResponse.getInt("from")
12
+    //val toNbr: Int = jsonResponse.getInt("to")
13
+    var songs : ArrayList<Song> = ArrayList()
14
+
15
+    init {
16
+        val songList = jsonResponse.getJSONArray("data")
17
+        for (i in 0 until songList.length())
18
+        {
19
+
20
+            val title = (songList[i] as JSONObject).getString("title")
21
+            val artist = (songList[i] as JSONObject).getString("artist")
22
+            val id = (songList[i] as JSONObject).getInt("id")
23
+
24
+            val s = Song("", id)
25
+            s.title.value = title
26
+            s.artist.value = artist
27
+            s.isRequestable = (songList[i] as JSONObject).getBoolean("requestable")
28
+            // TODO add the time before being requestable.
29
+            songs.add(s)
30
+        }
31
+    }
32
+
33
+    override fun toString(): String {
34
+        var s = ""
35
+        for (i in 0 until songs.size)
36
+        {
37
+            s += (songs[i].artist.value + " - " + songs[i].title.value + " | ")
38
+        }
39
+        return s
40
+    }
41
+
42
+}

+ 148 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestSongAdapter.kt View File

@@ -0,0 +1,148 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import android.annotation.SuppressLint
4
+import android.os.Build
5
+import android.util.TypedValue
6
+import android.view.LayoutInflater
7
+import android.view.ViewGroup
8
+import android.widget.TextView
9
+import androidx.constraintlayout.widget.ConstraintLayout
10
+import androidx.core.widget.TextViewCompat
11
+import androidx.recyclerview.widget.RecyclerView
12
+import io.r_a_d.radio2.*
13
+import io.r_a_d.radio2.playerstore.Song
14
+import kotlinx.android.synthetic.main.request_song_view.view.*
15
+import android.view.View
16
+import kotlinx.android.synthetic.main.button_load_more.view.*
17
+import android.R.attr.name
18
+import android.text.method.TextKeyListener.clear
19
+import android.util.Log
20
+import java.util.*
21
+import kotlin.collections.ArrayList
22
+
23
+
24
+class RequestSongAdapter(private val dataSet: ArrayList<Song>
25
+    /*,
26
+    context: Context,
27
+    resource: Int,
28
+    objects: Array<out Song>*/
29
+) : RecyclerView.Adapter<RequestSongAdapter.MyViewHolder>() /*ArrayAdapter<Song>(context, resource, objects)*/ {
30
+
31
+    private val viewTypeCell = 1 // normal cell with song and request button
32
+    private val viewTypeFooter = 2 // the bottom cell should be the "load more" button whenever needed
33
+
34
+    // Provide a reference to the views for each data item
35
+    // Complex data items may need more than one view per item, and
36
+    // you provide access to all the views for a data item in a view holder.
37
+    // Each data item is just a string in this case that is shown in a TextView.
38
+    class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view)
39
+
40
+
41
+    // Create new views (invoked by the layout manager)
42
+    override fun onCreateViewHolder(parent: ViewGroup,
43
+                                    viewType: Int): MyViewHolder {
44
+        // create a new view
45
+
46
+        val view =
47
+            if (viewType == viewTypeCell)
48
+                LayoutInflater.from(parent.context).inflate(R.layout.request_song_view, parent, false) as ConstraintLayout
49
+            else
50
+                LayoutInflater.from(parent.context).inflate(R.layout.button_load_more, parent, false) as ConstraintLayout
51
+
52
+        // set the view's size, margins, paddings and layout parameters
53
+        //...
54
+        return MyViewHolder(view)
55
+    }
56
+
57
+    // Replace the contents of a view (invoked by the layout manager)
58
+    @SuppressLint("SetTextI18n")
59
+    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
60
+
61
+        if (itemCount <= 1)
62
+        {
63
+            // in any case, if there's nothing, don't display the loadMore button!!
64
+            holder.itemView.loadMoreButton.visibility = View.GONE
65
+            return
66
+        }
67
+
68
+        if (holder.itemViewType == viewTypeFooter)
69
+        {
70
+            if (Requestor.instance.isLoadMoreVisible)
71
+                holder.itemView.loadMoreButton.visibility = View.VISIBLE
72
+            else
73
+                holder.itemView.loadMoreButton.visibility = View.GONE
74
+            holder.itemView.loadMoreButton.text = "Load more results"
75
+            holder.itemView.loadMoreButton.setOnClickListener{
76
+                Requestor.instance.loadMore()
77
+            }
78
+            return
79
+        }
80
+
81
+        val artist = holder.itemView.findViewById<TextView>(R.id.request_song_artist)
82
+        val title = holder.itemView.findViewById<TextView>(R.id.request_song_title)
83
+        val button = holder.itemView.request_button
84
+
85
+        if (dataSet[position].isRequestable) {
86
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) // for API21+ Material Design makes ripples on the button.
87
+                button.supportBackgroundTintList = colorGreenList
88
+            else // But on API20- no Material Design support, so we add some more color when clicked
89
+                button.supportBackgroundTintList = colorGreenListCompat
90
+            button.isEnabled = true
91
+            button.isClickable = true
92
+            button.setOnClickListener {
93
+                Requestor.instance.request(dataSet[position].id)
94
+            }
95
+        } else {
96
+            button.supportBackgroundTintList = colorRedList
97
+            button.isEnabled = false
98
+            button.isClickable = false
99
+        }
100
+
101
+        TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
102
+            button,2, 24, 2, TypedValue.COMPLEX_UNIT_SP)
103
+        artist.text = dataSet[position].artist.value
104
+        title.text = dataSet[position].title.value
105
+    }
106
+
107
+    // Return the size of your dataset (invoked by the layout manager)
108
+    override fun getItemCount() = dataSet.size + 1 // add 1 for the "Load more results" button
109
+
110
+    override fun getItemViewType(position: Int): Int {
111
+        return if (position == dataSet.size) viewTypeFooter else viewTypeCell
112
+    }
113
+
114
+    /*
115
+    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
116
+        // create a new view
117
+        val view = LayoutInflater.from(parent.context)
118
+            .inflate(R.layout.song_view, parent, false) as ConstraintLayout
119
+    }
120
+    */
121
+
122
+    // a filtering function. As naive as it could be, but it should work.
123
+    private val dataSetOrig = ArrayList<Song>()
124
+    init {
125
+        dataSetOrig.addAll(dataSet)
126
+    }
127
+
128
+    fun filter(entry: String) {
129
+        var text = entry
130
+        dataSet.clear()
131
+        Log.d(tag, "entering filter")
132
+        if (text.isEmpty()) {
133
+            dataSet.addAll(dataSetOrig)
134
+        } else {
135
+            text = text.toLowerCase(locale = Locale.ROOT)
136
+            for (item in dataSetOrig) {
137
+                Log.d(tag, "$text, ${item.artist.value!!.toLowerCase(locale = Locale.ROOT)}, ${item.title.value!!.toLowerCase(locale = Locale.ROOT)}")
138
+                if (item.artist.value!!.toLowerCase(locale = Locale.ROOT).contains(text) ||
139
+                    item.title.value!!.toLowerCase(locale = Locale.ROOT).contains(text)) {
140
+                    dataSet.add(item)
141
+                }
142
+            }
143
+        }
144
+        notifyDataSetChanged()
145
+    }
146
+
147
+}
148
+

+ 301 - 0
app/src/main/java/io/r_a_d/radio2/ui/songs/request/Requestor.kt View File

@@ -0,0 +1,301 @@
1
+package io.r_a_d.radio2.ui.songs.request
2
+
3
+import android.util.Log
4
+import androidx.lifecycle.MutableLiveData
5
+import io.r_a_d.radio2.ActionOnError
6
+import io.r_a_d.radio2.Async
7
+import io.r_a_d.radio2.playerstore.Song
8
+import io.r_a_d.radio2.preferenceStore
9
+import io.r_a_d.radio2.tag
10
+import org.json.JSONArray
11
+import org.json.JSONException
12
+import org.json.JSONObject
13
+import java.io.BufferedReader
14
+import java.io.IOException
15
+import java.io.InputStreamReader
16
+import java.net.CookieHandler
17
+import java.net.CookieManager
18
+import java.net.MalformedURLException
19
+import java.net.URL
20
+import java.util.*
21
+import java.util.regex.Pattern
22
+
23
+import javax.net.ssl.HttpsURLConnection
24
+import kotlin.collections.ArrayList
25
+import kotlin.random.Random
26
+
27
+/**
28
+ * Requests a song via the website's API
29
+ *
30
+ * We scrape the website for a CSRF token and POST it to /request/ endpoint with
31
+ * the song id
32
+ *
33
+ * Created by Kethsar on 1/2/2017.
34
+ * Converted to Kotlin and adapted by Yattoz on 05 Nov. 2019
35
+ */
36
+
37
+class Requestor {
38
+    var addRequestMeta: String = ""
39
+    private val cookieManager: CookieManager = CookieManager()
40
+    private val requestUrl = "https://r-a-d.io/request/%1\$d"
41
+    private val searchUrl = "https://r-a-d.io/api/search/%1s?page=%2\$d"
42
+    private val favoritesUrl = "https://r-a-d.io/faves/%1s?dl=true"
43
+    private val songThresholdStep = 50
44
+    private var songThreshold = songThresholdStep
45
+    private var localQuery = ""
46
+
47
+    private var token: String? = null
48
+    val snackBarText : MutableLiveData<String?> = MutableLiveData()
49
+    private var responseArray : ArrayList<RequestResponse> = ArrayList()
50
+    val requestSongArray : ArrayList<Song> = ArrayList()
51
+    val favoritesSongArray : ArrayList<Song> = ArrayList()
52
+    val isRequestResultUpdated : MutableLiveData<Boolean> = MutableLiveData()
53
+    val isFavoritesUpdated : MutableLiveData<Boolean> = MutableLiveData()
54
+    var isLoadMoreVisible: Boolean = false
55
+
56
+
57
+    init {
58
+        snackBarText.value = ""
59
+        isRequestResultUpdated.value = false
60
+        isFavoritesUpdated.value = false
61
+        isLoadMoreVisible = false
62
+    }
63
+
64
+    fun initFavorites(userName : String? = preferenceStore.getString("userName", null)){
65
+        Log.d(tag, "initializing favorites")
66
+        favoritesSongArray.clear()
67
+        if (userName == null)
68
+        {
69
+            // Display is done by default in the XML.
70
+            Log.d(tag, "no user name set for favorites")
71
+            isFavoritesUpdated.value = true
72
+            return
73
+        }
74
+        val favoritesUserUrl = String.format(Locale.getDefault(), favoritesUrl, userName)
75
+        val scrapeFavorites : (Any?) -> JSONArray = {
76
+            JSONArray(URL(favoritesUserUrl).readText())
77
+        }
78
+        val postFavorites :  (Any?) -> Unit = {
79
+            val res = it as JSONArray
80
+            for (i in 0 until (res).length())
81
+            {
82
+                val item = res.getJSONObject(i)
83
+                val artistTitle = item.getString("meta")
84
+                val id : Int? = if (item.isNull("tracks_id"))
85
+                    null
86
+                else
87
+                    item.getInt("tracks_id")
88
+
89
+                val lastRequested : Int? = if (item.isNull("lastrequested")) null else item.getInt("lastrequested")
90
+                val lastPlayed : Int? = if (item.isNull("lastplayed")) null else item.getInt("lastplayed")
91
+                val requestCount : Int? = if (item.isNull("requestcount")) null else item.getInt("requestcount")
92
+                val isRequestable = (coolDown(lastPlayed, lastRequested, requestCount) < 0)
93
+                //Log.d(tag, "val : $id")
94
+                favoritesSongArray.add(Song(artistTitle, id ?: 0, isRequestable))
95
+            }
96
+            Log.d(tag, "favorites : $favoritesSongArray")
97
+            isFavoritesUpdated.value = true
98
+        }
99
+        Async(scrapeFavorites, postFavorites, ActionOnError.NOTIFY)
100
+    }
101
+
102
+    fun search(query: String)
103
+    {
104
+        responseArray.clear()
105
+        requestSongArray.clear()
106
+        localQuery = query
107
+        searchPage(query, 1) // the searchPage function is recursive to get all pages.
108
+    }
109
+
110
+    private fun searchPage(query: String, pageNumber : Int)
111
+    {
112
+        val searchURL = String.format(Locale.getDefault(), searchUrl, query, pageNumber)
113
+        val scrape : (Any?) -> JSONObject = {
114
+            val res = URL(searchURL).readText()
115
+            val json = JSONObject(res)
116
+            json
117
+        }
118
+        val post : (Any?) -> Unit = {
119
+            val response = RequestResponse(it as JSONObject)
120
+
121
+            responseArray.add(response)
122
+            for (i in 0 until response.songs.size)
123
+            {
124
+                requestSongArray.add(response.songs[i])
125
+            }
126
+            isRequestResultUpdated.value = true
127
+            if (requestSongArray.size >= songThreshold)
128
+            {
129
+                isLoadMoreVisible = true
130
+
131
+            } else {
132
+                if (response.currentPage < response.lastPage)
133
+                    searchPage(query, pageNumber + 1) // recursive call to get the next page
134
+                else
135
+                    finishSearch()
136
+            }
137
+
138
+        }
139
+        Async(scrape, post, ActionOnError.NOTIFY)
140
+    }
141
+
142
+    private fun finishSearch()
143
+    {
144
+        isLoadMoreVisible = false
145
+    }
146
+
147
+    fun reset()
148
+    {
149
+        requestSongArray.clear()
150
+        responseArray.clear()
151
+        isRequestResultUpdated.value = false
152
+        songThreshold = songThresholdStep
153
+    }
154
+
155
+    fun loadMore()
156
+    {
157
+        songThreshold += songThresholdStep
158
+        searchPage(localQuery, responseArray.last().currentPage + 1)
159
+    }
160
+
161
+
162
+    /**
163
+     * Scrape the website for the CSRF token required for requesting
164
+     * scrapeToken and postToken are the two lambas run by the Async() class.
165
+     */
166
+
167
+    private val scrapeToken : (Any?) -> Any? = {
168
+        val radioSearchUrl = "https://r-a-d.io/search"
169
+        var searchURL: URL? = null
170
+        var retVal: String? = null
171
+        var reader: BufferedReader? = null
172
+
173
+        CookieHandler.setDefault(cookieManager) // it[0] ??
174
+
175
+        try {
176
+            searchURL = URL(radioSearchUrl)
177
+        } catch (e: MalformedURLException) {
178
+            e.printStackTrace()
179
+        }
180
+
181
+        try {
182
+            reader = BufferedReader(InputStreamReader(searchURL!!.openStream(), "UTF-8"))
183
+            var line: String?
184
+            line = reader.readLine()
185
+            while (line != null)
186
+            {
187
+                line = line.trim { it <= ' ' }
188
+                val p = Pattern.compile("value=\"(\\w+)\"")
189
+                val m = p.matcher(line)
190
+
191
+                if (line.startsWith("<form")) {
192
+                    if (m.find()) {
193
+                        retVal = m.group(1)
194
+                        break
195
+                    }
196
+                }
197
+                line = reader.readLine()
198
+            }
199
+        } catch (e: IOException) {
200
+            e.printStackTrace()
201
+        } finally {
202
+            if (reader != null) try {
203
+                reader.close()
204
+            } catch (ignored: IOException) {
205
+            }
206
+
207
+        }
208
+        retVal
209
+    }
210
+
211
+    private val postToken : (Any?) -> (Unit) = {
212
+        token = it as String?
213
+    }
214
+
215
+    /**
216
+     * Request the song with the CSRF token that was scraped
217
+     */
218
+    private val requestSong: (Any?) -> Any? = {
219
+        val reqString = it as String
220
+        var response = ""
221
+
222
+        try {
223
+            val reqURL = URL(reqString)
224
+            val conn = reqURL.openConnection() as HttpsURLConnection
225
+            val tokenObject = JSONObject()
226
+
227
+            tokenObject.put("_token", token)
228
+            val requestBytes = tokenObject.toString().toByteArray()
229
+
230
+            conn.requestMethod = "POST"
231
+            conn.doOutput = true
232
+            conn.doInput = true
233
+            conn.setChunkedStreamingMode(0)
234
+            conn.setRequestProperty("Content-Type", "application/json")
235
+
236
+            val os = conn.outputStream
237
+            os.write(requestBytes)
238
+
239
+            val responseCode = conn.responseCode
240
+
241
+            if (responseCode == HttpsURLConnection.HTTP_OK) {
242
+                var line: String?
243
+                val br = BufferedReader(InputStreamReader(
244
+                    conn.inputStream))
245
+                line = br.readLine()
246
+                while (line != null) {
247
+                    response += line
248
+                    line = br.readLine()
249
+                }
250
+            } else {
251
+                response += ""
252
+            }
253
+        } catch (ex: IOException) {
254
+            ex.printStackTrace()
255
+        } catch (ex: JSONException) {
256
+            ex.printStackTrace()
257
+        }
258
+
259
+        response
260
+    }
261
+
262
+    private val postSong  : (Any?) -> (Unit) = {
263
+        val response = JSONObject(it as String)
264
+        val key = response.names()!!.get(0) as String
265
+        val value = response.getString(key)
266
+
267
+        snackBarText.postValue(addRequestMeta + value)
268
+    }
269
+
270
+
271
+    fun request(songID: Int?) {
272
+        val requestSongUrl = String.format(requestUrl, songID!!)
273
+        if (token == null) {
274
+            Async(scrapeToken, postToken, ActionOnError.NOTIFY)
275
+        }
276
+        Async(requestSong, postSong, ActionOnError.NOTIFY, requestSongUrl)
277
+    }
278
+
279
+    fun raF() : Song {
280
+        // request a random favorite song. HELL YEAH
281
+        val requestableSongArray = ArrayList<Song>()
282
+        for (i in 0 until favoritesSongArray.size)
283
+        {
284
+            if (favoritesSongArray[i].isRequestable && (favoritesSongArray[i].id ?: 0) > 0)
285
+                requestableSongArray.add(favoritesSongArray[i])
286
+        }
287
+        return if (requestableSongArray.isNotEmpty()) {
288
+            val songNbr =  Random(System.currentTimeMillis()).nextInt(1, requestableSongArray.size)
289
+            requestableSongArray[songNbr]
290
+        } else {
291
+            Song("No song requestable - ")
292
+        }
293
+    }
294
+
295
+    companion object {
296
+        val instance by lazy {
297
+            Requestor()
298
+        }
299
+    }
300
+
301
+}

+ 8 - 0
app/src/main/res/color/button_green.xml View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
3
+    <item android:state_pressed="true"
4
+        android:color="@color/reqButtonNormal"/> <!-- pressed -->
5
+    <item android:state_focused="true"
6
+        android:color="@color/reqButtonNormal"/> <!-- focused -->
7
+    <item android:color="@color/reqButtonNormal"/> <!-- default -->
8
+</selector>

+ 8 - 0
app/src/main/res/color/button_green_compat.xml View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
3
+    <item android:state_pressed="true"
4
+        android:color="@color/reqButtonPressed"/> <!-- pressed -->
5
+    <item android:state_focused="true"
6
+        android:color="@color/reqButtonNormal"/> <!-- focused -->
7
+    <item android:color="@color/reqButtonNormal"/> <!-- default -->
8
+</selector>

+ 8 - 0
app/src/main/res/color/button_red.xml View File

@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
3
+    <item android:state_pressed="true"
4
+        android:color="@color/reqButtonDisabled"/> <!-- pressed -->
5
+    <item android:state_focused="true"
6
+        android:color="@color/reqButtonDisabled"/> <!-- focused -->
7
+    <item android:color="@color/reqButtonDisabled"/> <!-- default -->
8
+</selector>

File diff suppressed because it is too large
+ 13 - 0
app/src/main/res/drawable-anydpi-v24/ic_stat_new_message.xml


+ 11 - 0
app/src/main/res/drawable-anydpi/ic_alarm.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM12.5,8L11,8v6l4.75,2.85 0.75,-1.23 -4,-2.37L12.5,8zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_av_timer.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M7,13c1.66,0 3,-1.34 3,-3S8.66,7 7,7s-3,1.34 -3,3 1.34,3 3,3zM19,7h-8v7L3,14L3,5L1,5v15h2v-3h18v3h2v-9c0,-2.21 -1.79,-4 -4,-4z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_bug.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_chat_processing.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_customize.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M3,17v2h6v-2L3,17zM3,5v2h10L13,5L3,5zM13,21v-2h8v-2h-8v-2h-2v6h2zM7,9v2L3,11v2h4v2h2L9,9L7,9zM21,13v-2L11,11v2h10zM15,9h2L17,7h4L21,5h-4L17,3h-2v6z"/>
11
+</vector>

+ 10 - 0
app/src/main/res/drawable-anydpi/ic_newspaper.xml View File

@@ -0,0 +1,10 @@
1
+<!-- drawable/newspaper.xml -->
2
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3
+    android:width="24dp"
4
+    android:height="24dp"
5
+    android:viewportWidth="24"
6
+    android:viewportHeight="24"
7
+    android:tint="#FFFFFF"
8
+    android:alpha="0.8">
9
+    <path android:fillColor="#FF000000" android:pathData="M20,11H4V8H20M20,15H13V13H20M20,19H13V17H20M11,19H4V13H11M20.33,4.67L18.67,3L17,4.67L15.33,3L13.67,4.67L12,3L10.33,4.67L8.67,3L7,4.67L5.33,3L3.67,4.67L2,3V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V3L20.33,4.67Z" />
10
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_notification.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_open_in_browser.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z"/>
11
+</vector>

+ 11 - 0
app/src/main/res/drawable-anydpi/ic_playlist_music.xml View File

@@ -0,0 +1,11 @@
1
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+    android:width="24dp"
3
+    android:height="24dp"
4
+    android:viewportWidth="24"
5
+    android:viewportHeight="24"
6
+    android:tint="#FFFFFF"
7
+    android:alpha="0.8">
8
+    <path
9
+        android:fillColor="#FF000000"
10
+        android:pathData="M15,6L3,6v2h12L15,6zM15,10L3,10v2h12v-2zM3,16h8v-2L3,14v2zM17,6v8.18c-0.31,-0.11 -0.65,-0.18 -1,-0.18 -1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3L19,8h3L22,6h-5z"/>
11
+</vector>

BIN
app/src/main/res/drawable-hdpi/ic_alarm.png View File


BIN
app/src/main/res/drawable-hdpi/ic_av_timer.png View File


BIN
app/src/main/res/drawable-hdpi/ic_bug.png View File


BIN
app/src/main/res/drawable-hdpi/ic_chat_processing.png View File


BIN
app/src/main/res/drawable-hdpi/ic_customize.png View File


BIN
app/src/main/res/drawable-hdpi/ic_newspaper.png View File


BIN
app/src/main/res/drawable-hdpi/ic_notification.png View File


BIN
app/src/main/res/drawable-hdpi/ic_open_in_browser.png View File


BIN
app/src/main/res/drawable-hdpi/ic_pause.png View File


BIN
app/src/main/res/drawable-hdpi/ic_play.png View File


BIN
app/src/main/res/drawable-hdpi/ic_playlist_music.png View File


BIN
app/src/main/res/drawable-hdpi/ic_stat_new_message.png View File


BIN
app/src/main/res/drawable-hdpi/ic_stat_now_playing.png View File


BIN
app/src/main/res/drawable-hdpi/ic_stop.png View File


BIN
app/src/main/res/drawable-hdpi/ic_volume_high.png View File


BIN
app/src/main/res/drawable-hdpi/ic_volume_low.png View File


BIN
app/src/main/res/drawable-hdpi/ic_volume_medium.png View File


BIN
app/src/main/res/drawable-hdpi/ic_volume_off.png View File


BIN
app/src/main/res/drawable-ldpi/ic_newspaper.png View File


BIN
app/src/main/res/drawable-ldpi/ic_pause.png View File


+ 0 - 0
app/src/main/res/drawable-ldpi/ic_play.png View File


Some files were not shown because too many files changed in this diff