├── .gitignore
├── .gradle
├── 7.5
│ ├── checksums
│ │ └── checksums.lock
│ ├── dependencies-accessors
│ │ ├── dependencies-accessors.lock
│ │ └── gc.properties
│ ├── executionHistory
│ │ ├── executionHistory.bin
│ │ └── executionHistory.lock
│ ├── fileChanges
│ │ └── last-build.bin
│ ├── fileHashes
│ │ ├── fileHashes.bin
│ │ ├── fileHashes.lock
│ │ └── resourceHashesCache.bin
│ └── gc.properties
├── buildOutputCleanup
│ ├── buildOutputCleanup.lock
│ ├── cache.properties
│ └── outputFiles.bin
├── file-system.probe
└── vcs-1
│ └── gc.properties
├── README.md
├── androidmusicserverscreenshot.png
├── androidmusicserverscreenshot2.png
├── apk
├── app-debug.apk
└── app-release.apk
├── app
├── build.gradle
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── assets
│ └── docroot
│ │ ├── default_cover.png
│ │ ├── functions.js
│ │ ├── logo.png
│ │ ├── nextbutton.png
│ │ ├── pausebutton.png
│ │ ├── playbutton.png
│ │ ├── prevbutton.png
│ │ ├── stopbutton.png
│ │ ├── styles.css
│ │ ├── vol_down.png
│ │ └── vol_up.png
│ ├── java
│ └── net
│ │ └── kevinboone
│ │ ├── androidmediaserver
│ │ ├── AndroidNetworkUtil.java
│ │ ├── CoverUtils.java
│ │ ├── FileUtils.java
│ │ ├── Main.java
│ │ ├── NanoHTTPD.java
│ │ ├── SettingsActivity.java
│ │ ├── Version.java
│ │ ├── WebPlayerService.java
│ │ ├── WebServer.java
│ │ └── client
│ │ │ ├── Client.java
│ │ │ ├── ClientException.java
│ │ │ └── Status.java
│ │ ├── androidmusicplayer
│ │ ├── AlreadyAtEndOfPlaylistException.java
│ │ ├── AlreadyAtStartOfPlaylistException.java
│ │ ├── AndroidEqUtil.java
│ │ ├── AudioDatabase.java
│ │ ├── Errors.java
│ │ ├── Player.java
│ │ ├── PlayerException.java
│ │ ├── PlayerIOException.java
│ │ ├── PlaylistEmptyException.java
│ │ ├── PlaylistIndexOutOfRangeException.java
│ │ ├── RemoteControlReceiver.java
│ │ ├── SearchSpec.java
│ │ └── TrackInfo.java
│ │ └── textutils
│ │ └── EscapeUtils.java
│ └── res
│ ├── drawable-hdpi
│ └── ic_launcher.png
│ ├── drawable-ldpi
│ └── ic_launcher.png
│ ├── drawable-mdpi
│ └── ic_launcher.png
│ ├── drawable-xhdpi
│ ├── button_next.png
│ ├── button_pause.png
│ ├── button_play.png
│ ├── button_prev.png
│ ├── button_settings.png
│ ├── button_shutdown.png
│ ├── button_stop.png
│ └── ic_launcher.png
│ ├── layout
│ └── main.xml
│ ├── raw
│ └── template.html
│ ├── values
│ └── strings.xml
│ └── xml
│ ├── network_security_config.xml
│ └── preferences.xml
├── build.gradle
├── fastlane
└── metadata
│ └── android
│ ├── de
│ └── short_description.txt
│ └── en-US
│ ├── full_description.txt
│ ├── images
│ ├── icon.png
│ └── phoneScreenshots
│ │ ├── androidmusicserverscreenshot.jpg
│ │ ├── androidmusicserverscreenshot.png
│ │ ├── androidmusicserverscreenshot2.jpg
│ │ └── androidmusicserverscreenshot2.png
│ └── short_description.txt
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── local.properties
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | local.properties
2 | app/build/
3 | .gradle/
4 |
--------------------------------------------------------------------------------
/.gradle/7.5/checksums/checksums.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/checksums/checksums.lock
--------------------------------------------------------------------------------
/.gradle/7.5/dependencies-accessors/dependencies-accessors.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/dependencies-accessors/dependencies-accessors.lock
--------------------------------------------------------------------------------
/.gradle/7.5/dependencies-accessors/gc.properties:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/dependencies-accessors/gc.properties
--------------------------------------------------------------------------------
/.gradle/7.5/executionHistory/executionHistory.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/executionHistory/executionHistory.bin
--------------------------------------------------------------------------------
/.gradle/7.5/executionHistory/executionHistory.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/executionHistory/executionHistory.lock
--------------------------------------------------------------------------------
/.gradle/7.5/fileChanges/last-build.bin:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gradle/7.5/fileHashes/fileHashes.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/fileHashes.bin
--------------------------------------------------------------------------------
/.gradle/7.5/fileHashes/fileHashes.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/fileHashes.lock
--------------------------------------------------------------------------------
/.gradle/7.5/fileHashes/resourceHashesCache.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/resourceHashesCache.bin
--------------------------------------------------------------------------------
/.gradle/7.5/gc.properties:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/gc.properties
--------------------------------------------------------------------------------
/.gradle/buildOutputCleanup/buildOutputCleanup.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/buildOutputCleanup/buildOutputCleanup.lock
--------------------------------------------------------------------------------
/.gradle/buildOutputCleanup/cache.properties:
--------------------------------------------------------------------------------
1 | #Sat Jan 14 12:29:56 GMT 2023
2 | gradle.version=7.5
3 |
--------------------------------------------------------------------------------
/.gradle/buildOutputCleanup/outputFiles.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/buildOutputCleanup/outputFiles.bin
--------------------------------------------------------------------------------
/.gradle/file-system.probe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/file-system.probe
--------------------------------------------------------------------------------
/.gradle/vcs-1/gc.properties:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/vcs-1/gc.properties
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # androidmusicserver
2 |
3 | Version 0.0.9, January 2023
4 |
5 | A web interface to the Android audio player -- control your media
6 | playback using a browser.
7 |
8 | ## Warning -- old, old code
9 |
10 | Please note that this app has been largely unchanged since 2015.
11 | I've made only the minimum necessary changes to keep it working
12 | on the Android devices I own. The most recent device I've tested
13 | is the Samsung Galaxy s10.
14 |
15 | The app has many problems. The genre support that I mentioned as being
16 | problematic back in 2015 remains a problem -- it's shockingly slow
17 | (minutes, with more than a hundred or so albums). This is a crude,
18 | unsatisfactory app, and I only continue to
19 | maintain it because I can't find anything else that does
20 | the same thing. If anybody knows of a superior alternative -- ideally
21 | open-source -- please tell me, so I can let this project quietly
22 | fade away.
23 |
24 | Please bear in mind that the latest Android version that this app can
25 | currently target is 4.4 (API level 19). While it does seem to work
26 | on later devices, this API level is too early for the app to be accepted
27 | by any app store that I know of, even if I wanted to publish it that
28 | way. Although the app builds with the SDK for API level 31, it fails
29 | strangely on many devices.
30 |
31 | As I said, this is very old code, that really ought to be allowed to
32 | rest in peace.
33 |
34 | ## What is this?
35 |
36 | Android Music Server provides a web browser interface to
37 | control playback of audio files stored on most modern (4.x-11.0)
38 | Android devices.
39 | This allows the Android device
40 | to be used as a music server, in multi-room audio applications, among
41 | other things. I normally keep my Android phone docked, with a permanent
42 | USB connection to an audio DAC. This arrangement produces good quality
43 | audio playback, but I don't always have the pone within reach. It's
44 | awkward to fiddle with the little screen when it's docked, anyway.
45 | Providing a web interface -- albeit a crude one -- allows me to
46 | control playback from a web browser.
47 |
48 | Audio tracks can be selected using the browser from a list of albums, or
49 | directly from the filesystem (but see notes below).
50 | You can restrict the album listing to particular
51 | genres or particular artists rather than displaying all
52 | albums on the same page. Album cover
53 | art images can be displayed (but see notes below about this, too).
54 |
55 | The browser user interface looks like this:
56 |
57 | 
58 |
59 | While the app itself (which will probably never be used, apart from
60 | starting and stopping it) looks like this:
61 |
62 | 
63 |
64 | Android Music Server uses no Android feature introduced since about 2015, so
65 | it stands a chance of working on any relatively modern device. The most
66 | recent reported to work is the Samsung S10, but there's no particular
67 | reason why more recent versions won't work.
68 |
69 | Android Music Server is open-source, free of charge, and has no advertisements.
70 | It's easy to build from source if you have the Android SDK available.
71 |
72 |
73 | ## Features
74 | - Simple web interface -- works on most desktop web browsers and many mobile browsers
75 | - Integrates with the Android media catalogue -- browse by album, artist, genre, composer, or track
76 | - Supports file/folder browsing (if the Android version does)
77 | - Media catalogue text search
78 | - Equalizer
79 | - Cover art (both baked-in and album-folder images)
80 | - Playback control by headset or remote control
81 |
82 | ## Installation
83 |
84 | Android Music Server will never be available from any any app store, because I
85 | can't afford to pay money to distribute stuff free-of-charge. Sorry.
86 |
87 | To install, download the APK package from the Downloads section at the
88 | end of this page, copy it to your Android
89 | device, and install it using any file manager. Or simply download the APK
90 | directly from this page using your Android device's Web browser.
91 | You may have to tell
92 | your device to allow apps from unknown suppliers. If you're worried that this app might
93 | transmit all your secret passwords to villains, you're welcome to inspect
94 | and build the application yourself.
95 |
96 | ## Building
97 |
98 | This version of Android Music Sever is designed be built using gradle,
99 | the gradle Android plugin, and the Android SDK for API level 19. With these
100 | things all in place, you should be able to build using:
101 |
102 | $ ./gradlew build
103 |
104 | You may need to create a `local.properties` file indicating the location
105 | of the Android SDK files:
106 |
107 | sdk.dir=/home/foo/Android/Sdk
108 |
109 | A successful build will produce APK files in `app/build/outputs/apk`.
110 |
111 | ## Permissions
112 |
113 | Android Music Server will request the "Read SD card" permission. In Android
114 | terminology, "SD card" covers both internal and plug-in storage.
115 |
116 | ## Operation
117 |
118 | Android Music Server is designed to run quietly in the background, so it
119 | has no complicated Android user interface. When you start the app,
120 | if all is well you'll see the URL to which you should point your web browser.
121 | The Web server listens on port 30000 by default, but this can be
122 | changed from the settings page.
123 | If there are problems, which will generally be network-related, you'll
124 | see an error message. The app displays some information about the audio
125 | track that is currently playing, if there is one, and provides some
126 | buttons to control playback, in a rudimentary way. The "Shutdown" button
127 | shuts the application down completely, including its background
128 | service.
129 |
130 | All real operation of the app is from a Web browser. I hope that the
131 | browser interface is relatively self-explanatory -- just select an
132 | album from one of the various lists and click "Play now", or "Add"
133 | to append the
134 | tracks to the playlist. Alternatively, click the "Files" link at
135 | the bottom of the page and navigate
136 | the filesystem to find some audio files. Or just click "Random" to
137 | play a randomly-selected album.
138 |
139 | You can operate Android Music Server using a web browser on the device
140 | itself (if the browser has adequate JavaScript support -- most now do);
141 | but the app
142 | is really intended to be operated from a browser on a different machine.
143 | It is intended for remote control of music playback; there are many
144 | good media players for on-device operation. In any event, the
145 | browser user interface may not display very well on a small screen.
146 |
147 | Android Music Server responds to remote control events -- from
148 | a bluetooth headset with control buttons, for example. In particular,
149 | it responds to play, pause, step, next track, and previous track events.
150 | Of course, for next track and previous track to work, there must be
151 | something in the playlist.
152 |
153 | ## Usage notes
154 |
155 | This app is intended to work with relatively modest
156 | collections of audio files, that are relatively tidily organized.
157 | All lists (of albums, artists, etc) are displayed on a single,
158 | possibly long page. In practice it seems to work reasonably well
159 | with collections of a few hundred albums, but the user interface
160 | will struggle with thousands of albums, particularly if
161 | you choose to display cover art. The capacity of the
162 | app in this regard really depends on the CPU speed and memory
163 | of the Android device. However, my experience is that even
164 | really fast, modern devices like the Samsung S10 don't devote a lot
165 | of resource to servicing remote clients.
166 |
167 | In general Android Music Server assumes
168 | (as Android generally does) that audio tracks are organized into
169 | albums, and that at least the album, title, and artist tags are
170 | filled in. To play an album in the right order it also assumes that
171 | the track number tag is filled in, or that the titles when arranged
172 | into alphabetical order will give the same ordering as the original
173 | album. Everything about this application will work somewhat better
174 | if files are thoroughly and consistently tagged -- but that's true
175 | of most music players. I'm told that "free music downloaders"
176 | (bootlegging utilities, in other words) do not fill in tags properly,
177 | and you can end up with two thousand tracks in the same album, all
178 | called "null". Still, if you sup with the Devil, as the saying goes,
179 | you're advised to use a long spoon.
180 |
181 | The web interface is completely stateless; that is, everything it
182 | needs to know is captured in the URL supplied by the browser. So
183 | you can
184 | freely bookmark albums, or artists, or filesystem locations for
185 | quick reference.
186 |
187 | Sadly, filesystem browsing won't work well with Android releases after
188 | 6.x or thereabouts -- the ability to read anything other than very
189 | specific locations has been removed. Worse, there's no
190 | reasonably-straightforward, reliable,
191 | robust, way to determine which filesystem locations might contain
192 | audio, and be readable. In my darker moments, I think that Google
193 | is deliberately trying to find new ways to break my apps. In any
194 | case, there's no point complaining to me about this -- go hassle
195 | Google, for what good that will do. In any case, the main page
196 | presents some fileststem locations that _might_ be readable but,
197 | then again, might not. You'll notice that, when requesting a
198 | file listing, the URL issued by the browser contains the attribute
199 | `path=/xxx`. If you happen to know which directory contains audio
200 | files, and can be read, you can edit (and, presumably, bookmark)
201 | the path manually. For example, if your device supports plug-in
202 | SD cards, the root of the SD card is probably something like
203 | `/storage/XXX-XXXX`, and you might be able to find the value of
204 | 'XXXX-XXXX' using a file manager.
205 |
206 | When browsing the filesystem (on devices that support this), you
207 | can add files one at a time to the playlist,
208 | or add the directory that
209 | contains them. The app will filter out playable audio files from other
210 | types, so it should be OK to click "Add" on a directory with mixed content.
211 | Note that the Add function in a directory only searches that specific
212 | directory -- it won't descend into subdirectories.
213 |
214 | If you connect a Bluetooth audio device (e.g., headset) whilst this app is
215 | playing (through speaker or wired headphones), then audio should automatically
216 | be diverted to the headset. However, you might need to stop the app,
217 | or at least stop playback, to route audio back to the speaker. This slightly
218 | odd behaviour is, so far as I know, not a feature of this program -- other
219 | Android media players behave the same way.
220 |
221 | The browser interface updates every five seconds, so don't expect
222 | mouse-clicks to be reflected immediately in the browser (although,
223 | of course, they should have immediate effect on the audio). This
224 | five-second update time is to reduce load on the Android device.
225 |
226 | If you click "Play now" on a track whilst an item in the playlist is being
227 | played, then playback will resume at the next playlist item when playback
228 | of the selected track finishes, if there is anything left to play in
229 | the playlist.
230 |
231 | You can control the volume of playback by clicking on the loudspeaker
232 | buttons in the menu bar at the top of each page, or by going to the
233 | "Equalizer, etc" link from the home page, and tweaking the volume
234 | slider.
235 | If you're using a headset, it might
236 | have its own volume controls; if it does, it probably sets the
237 | volume on the headset itself, not on the device. So, in that case,
238 | to get full volume you probably need to turn up the volume both on
239 | the headset and in this application.
240 |
241 | Not a feature of this app, but it's helpful to know that some folders that
242 | contain media can be removed from the oversight of the Android media scanner
243 | by creating an empty file called `.nomedia` in those directories.
244 | This can be useful for preventing ringtones and the like from appearing
245 | in the album list; but bear in mind that this trick affects all apps
246 | that use the media scanner.
247 |
248 | ## Cover art
249 |
250 | If you choose to browse albums including covers, then the
251 | app will attempt to find some cover art to display. The places it
252 | looks are as follows.
253 |
254 | First, the app will ask the Android media catalogue if any track in
255 | the specified album has an embedded image. If it does, then the first
256 | track that can provide an image does so. In practice, the Android media
257 | catalogue seems to be limited to returning "baked in" images, that is,
258 | images that are part of an ID3 tag or similar.
259 |
260 | Second, the app will look in the directory that contains the track file, for
261 | an image file that looks like it might contain cover art. At present, it
262 | considers files names `folder` or `cover`, perhaps
263 | with a leading "." (hidden files), and with extensions `.jpg` or `.png`.
264 | These names are in lower-case only. Naturally,
265 | this process will only produce good results if folders contain only tracks
266 | from the same album.
267 |
268 | The cover art extraction process is
269 | subject to a number of limitations.
270 |
271 | First, baked-in cover-art images can be quite large -- perhaps even photo-sized.
272 | Returning all the images on a page containing, say, a list of two hundred
273 | albums is a challenge for an Android device. If the browser is also on a
274 | mobile device, then the difficulty is compounded. The music server therefore
275 | attempts to avoid sending images if it can avoid doing so. It sets
276 | an `Expires` header one hour in the future for all images,
277 | and sets a
278 | `Last-Modified` header on all images based on the time the
279 | app starts up. In
280 | principle, therefore, the browser should not request images very
281 | frequently. But...
282 |
283 | Second, mobile browsers in particular are often stupid when it comes to
284 | handling date headers. Many ignore them completely, and just blindly
285 | request all images in every page. Apart from choosing to browse without
286 | covers, there's little that can be done to avoid this problem, if you
287 | have a stupid browser.
288 |
289 | Moreover, we don't know the actual last-modified
290 | time of a baked-in cover image, because it isn't stored. The
291 | music server uses its start-up time as the modification baseline, lacking
292 | any better information. What this means is that if you restart the app
293 | whilst the browser still has images in its cache, the browser will get
294 | confused: because the image has a last-modified date in the future, but
295 | in the browser's cache it has not yet expired.
296 | Clearing the browser cache usually fixes this
297 | problem.
298 |
299 | ## Genre support
300 |
301 | Android Music Server provides a list of genres, to make it easy
302 | to restrict the listing of
303 | albums to specific genres. Needless to say, for this to work the audio
304 | files must have valid genre tags. It doesn't really matter what they
305 | actually are, but they must at least be meaningful to the user.
306 |
307 | Querying the Android media catalogue for genre information is
308 | _excruciatingly_
309 | slow. I believe that there is some problem with the internal search
310 | implementation,
311 | which seems to require the whole genre catalogue to be expanded into tracks
312 | and then a query run against each track. Whatever the reason, with large
313 | numbers of tracks (more than a few hundred) some short-cuts have to be
314 | taken. The app therefore assumes that each track in an album has
315 | the same genre and, when searching which albums match a genre, only the
316 | first track is checked. Of course, it's not all that unusual for
317 | different tracks to have different genres in the same album, but testing
318 | them all is simply unfeasibly slow in Android.
319 |
320 | Genres that have no associated tracks are silently ignored.
321 |
322 | ## Artist support
323 |
324 | When an entry is selected from the Artist list, any album that contains
325 | at least one track attributed to that artist is included in the
326 | album list. That is, an album doesn't have to be limited to a single
327 | artist to be included in that artist's listing.
328 |
329 | It's not at all uncommon for an album to contain tracks by many different
330 | artists. If many albums are of that type, the artist list could be very
331 | long.
332 |
333 | Android Music Server is entirely at the mercy of the Android media scanner,
334 | when it comes to figuring out which tag in the audio file actually
335 | represents the artist. Many tag formats, particularly ID3v2, allow
336 | multiple artists, of multiple tasks. If all these tags are filled in,
337 | it's essentially pot luck which one will be used.
338 |
339 |
340 | ## Playlist operations
341 |
342 | On the Playlist page you can shuffle or clear the playlist, if
343 | it is not empty. Clicking either of the relevant links causes the
344 | page to refresh but, because the HTTP requests made using JavaScript
345 | are asynchronous, it can't be guaranteed that the playlist has changed
346 | on the server before the page is refreshed. You might need to refresh
347 | the page explicitly if changes to the playlist do not show up
348 | immediately.
349 |
350 | Shuffling only changes the order in which items appear in the playlist --
351 | if something is playing when you shuffle, the change in ordering will
352 | only be apparent when that track is finished.
353 |
354 |
355 | ## Settings
356 |
357 | The settings page (in the app's user interface, not the browser)
358 | provides some modest control over operation of
359 | the app -- the number of items displayed on each browser page, for
360 | example. There is no easy way to guess the appropriate settings --
361 | they depend on the capabilities of the Android device and of
362 | the web browser in use. If you are primarily interacting with the
363 | music server through a desktop browser, for example, you'll probably
364 | be able to set higher values of the number of items on each page.
365 |
366 | As with most Android settings pages, the changed settings take effect
367 | when you click the "back" button to get back to the main screen.
368 |
369 |
370 | ## Search
371 |
372 | There is a search box in the top menu bar of the web interface.
373 | The music server does
374 | a very simple, case-insensitive search for the text string,
375 | which may appear anywhere
376 | in any album, artist, composer, or track title. The number of matches
377 | of each category (album, artist, etc) that are displayed on
378 | the results page can be controlled using the
379 | Settings page.
380 |
381 |
382 | ## Android media catalogue issues
383 |
384 | Android maintains a catalogue of media files and their metadata (tags).
385 | When a file is added using a USB connection, or presented to the
386 | device on an SD card or similar, Android reads the metadata and updates
387 | the catalogue. Android Music Server relies entirely on this catalogue
388 | for information about albums, artists, etc. Two problems arise from the
389 | app's use of the media catalogue.
390 |
391 | First, the application has to scan the catalogue to get lists of
392 | albums, artists, etc., for the display. This process is not usually
393 | _very_ time-consuming, but not something that we want to do regularly.
394 | In principle, the music server could hook into the media scanner and
395 | rescan every time a file is added or deleted, but many files will be of
396 | no interest to the application (documents, pictures...), and rescanning
397 | like this could be overzealous. Instead, there is a link on the
398 | home page 'Rescan the media catalog.' This will cause the music server
399 | to rebuild its own lists of albums, artists, etc., from Android's catalogue.
400 | Of course, you could just restart the app.
401 |
402 | The second problem is that the media catalogue can sometimes get
403 | out-of-sync with the contents of storage. This isn't usually a problem
404 | with files added by USB, but can be a problem with files on removeable
405 | storage devices, and is particularly a problem if files are moved
406 | around using a general file manager (or, worse, at a command prompt,
407 | although most users probably won't do that).
408 |
409 | The link 'Rescan the filesystem' will ask Android to start a complete
410 | rescan of the filesystem. Android is completely at liberty to
411 | ignore this request and, in later versions, is increasingly willing to exactly
412 | that.
413 | With Android 5 and later, rebooting may be the only way to force a complete
414 | rescan. Note that the Music Server user interface will not wait
415 | for the rescan, and rescanning the filesystem does not imply that the
416 | music server app will rescan Android's media catalogue
417 | (because it has no way to know when the filesystem
418 | rescan is finished, if it even started.)
419 |
420 | ## Supported devices
421 |
422 | Android Music Server is known to work on at least the following
423 | devices. Feel free to report others that work or don't work.
424 |
425 | - Samsung Note 8, with Android 9.0
426 | - Google Nexus 7 second gen., with Android 4.4.4
427 | - Google Nexus 7 first gen., with Android 5.1.0
428 | - Samsung Galaxy S3, with Android 4.4.2
429 | - Samsung Note 3, with Android 4.4.2
430 |
431 | ## Limitations
432 |
433 | In general, Android Music Server supports whatever audio formats
434 | the device itself supports. When listing audio tracks by album/artist/etc
435 | you should never see anything that can't be played (unless it's actually
436 | a movie that Android has incorrectly identified as music). When listing
437 | files on the filesystem, you should also never see files that can't
438 | be played,
439 | because the app won't display files whose
440 | names do not end in a recognized audio extension, like .MP3 or .FLAC --
441 | it's just
442 | too time-consuming to have to scan each file and try to work out
443 | its contents. This does mean that some files that could, in fact,
444 | be played never get listed.
445 |
446 | The are particular issues regarding the display of cover art: please
447 | see the section "Cover art" above.
448 |
449 | Some Android devices are factory-configured to prevent _any_
450 | incoming network connection. Sorry but, without rooting the device,
451 | there's no way to change that, and this app simply won't work.
452 | Similarly, if your Android implementation shuts down the WiFi radio
453 | to save power when the screen blanks then, again, this app won't work.
454 |
455 | Android Music Server relies heavily on JavaScript to create and manage its
456 | web user interface. Your browser needs decent JavaScript support --
457 | the browser on the android device itself might not be up to the job
458 | (but Chrome, at least, seems to work pretty well.)
459 |
460 | Only WIFI operation is supported -- you won't be able to connect to the
461 | app over a mobile network. Even if the app allowed this, most likely
462 | the network would not.
463 |
464 | The app will not respond very well to changes in WIFI status -- if you
465 | change access points, for example -- and you'll probably need to restart
466 | it in such cases.
467 |
468 | Android Music Server relies for its tag (e.g., album)
469 | support entirely on the Android
470 | media scanner. If this isn't working (which is relatively common),
471 | results will be variable. The app queries the media scanner
472 | when it starts, so media added after starting may not be visible (even
473 | if the scanner is working), unless you click the "Rescan media catalogue"
474 | link in the home page. Please see the section 'Android media
475 | catalogue issues' for more information.
476 |
477 | One particular oddity of the Android media scanner is that it will sometimes
478 | present video files as 'Music,' presmumably because they have soundtracks.
479 | This app doesn't play video, so these entries in the album list are an
480 | irritation.
481 |
482 | Whilst you can skip forward and back between tracks in the playlist,
483 | there is no general foward/rewind facility within a specific
484 | track. This is a tricky thing to implement within a web interface.
485 |
486 | The app does not choose an open port for its built-in web server --
487 | this would be easy to implement, but users would probably prefer
488 | the port number to remain the same between sessions. If the port number
489 | clashes with something else, or is out of the permitted range, an
490 | error message should be displayed.
491 |
492 | There is quite a subtle limitation inherent in the way
493 | Android audio works. Android Music Server registers itself to receive
494 | remote control events (e.g., from a headset), but only when audio
495 | is playing. So you can select play/pause, next track, and previous
496 | track, if your headset has buttons for these functions. However,
497 | if you do a "stop" operation (if your hardware supports it), you
498 | won't be able to start again, or use remote control at all,
499 | until you resume playback using the web interface. The reason this limitation
500 | exists is that the app is designed to run in the background, and perhaps
501 | be idle a lot of the time. If it took over the remote control when it
502 | was running, it would prevent other media apps using the remote control.
503 | There are, in fact, a number of popular media players that suffer from
504 | this exact problem, and it can be quite a nuisance.
505 |
506 | *This app is, in essence, an unsecured web server*.
507 | It is not really intended for use in hostile environments.
508 |
509 | The user interface is currently English-language only.
510 |
511 | The part of Android Music Server that responds to HTTP requests and plays
512 | audio (i.e., most of it) is implemented as an Android background service.
513 | It is therefore less prone to automatic unloading than the user interface
514 | is. It's possible that Android might unload the user interface, whilst
515 | leaving the service running. This should be harmless, because when you
516 | restart the app, Android will not restart the service if it is still
517 | running. It's possible, in conditions of low memory, that Android will
518 | unload the service as well. In that case, Android is supposed to reload
519 | it when conditions improve, without user intervention. That process should
520 | also be transparent to the user except that, of course, whilst unloaded
521 | the service will not respond to HTTP requests. However, if the service
522 | is unloaded and reloading in this way, it will reinitialize, and
523 | the current playlist will be lost.
524 |
525 | The Android API specifies an interface by which app can control audio equalizer
526 | settings, but manufacturers do not have to implement it in any useful way.
527 | That is, the controls may not be connected to anything. "Bass boost" is
528 | particularly flakey -- on some devices it has an adjustable strength,
529 | on some it is just an on/off control, and on some it has no effect at all.
530 | Devices that provide their own, vendor-specific audio enhancers frequently
531 | do not implement the Android audio API at all.
532 | In any event, I am aware of few Android devices where the stock equalizer really
533 | works well. On some devices, I'm told that the equalizer API does not even
534 | initialize properly.
535 |
536 | Albums and tracks that appear in the search results are displayed either
537 | with, or without cover art -- the default is without. However, if you
538 | have recently browsed albums or tracks by cover, then covers will
539 | be included in search results as well.
540 |
541 | Finally, in this long list of limitations, there's the fact that
542 | changing the orientation of the device (portrait/landscape) will
543 | probably cause the app to reload. Because it's deliberately completely
544 | stateless, this will clear the playlist and stop playback. So long
545 | as the UI isn't actually visible -- and, frankly, it's nothing much
546 | to look at -- this isn't a problem.
547 |
548 | ## Legal and copying
549 |
550 | Android Music Server is open-source, and released under the terms
551 | of the GNU Public Licence, version 3.0. It contains components from
552 | a number of different authors. Please see the individual source
553 | files for detailed licencing and redistribution rights.
554 | The button icons are from the Tango icon set, released under the
555 | germs of the GNU Public Licence, version 2.0.
556 |
557 | Broadly,
558 | however, Android Music Server is free of charge and may be freely
559 | copied and distributed, so long as the original authors continue
560 | to be acknowledged.
561 |
562 | I wrote Android Music Server server for my own use; I'm
563 | publishing it in case somebody
564 | else might get some benefit from it -- even if it's only to look at the source
565 | code and see how not to write an Android app. It might work for you, it
566 | might not. If it doesn't, you're very welcome to fix it.
567 |
568 |
Revision history
569 |
570 |
571 |
572 |
573 | 0.0.8
574 |
575 |
576 | January 2023
577 |
578 |
579 | Refactored for gradle build and API 31
580 |
581 |
582 |
583 |
584 | 0.0.7
585 |
586 |
587 | November 2021
588 |
589 |
590 | Improved app screen layout a little
591 |
592 |
593 |
594 |
595 | 0.0.6
596 |
597 |
598 | October 2021
599 |
600 |
601 | Stopped the app crashing when a genre is "null" in the
602 | media database.
603 |
604 |
605 |
606 |
607 | 0.0.5
608 |
609 |
610 | June 12 2019
611 |
612 |
613 | Various bug fixes related to later Android releases
614 |
615 |
616 |
617 |
618 | 0.0.4
619 |
620 |
621 | April 18 2015
622 |
623 |
624 | Added search facility, and preferences page
625 |
626 |
627 |
628 |
629 | 0.0.3
630 |
631 |
632 | April 15 2015
633 |
634 |
635 | Added on-device status display and controls, and equalizer page
636 |
637 |
638 |
639 |
640 | 0.0.2
641 |
642 |
643 | April 12 2015
644 |
645 |
646 | Added preliminary cover art, and genre/artist filtering support
647 |
648 |
649 |
650 |
651 | 0.0.1
652 |
653 |
654 | April 3 2015
655 |
656 |
657 | First release
658 |
659 |
660 |
661 |
662 | ## Download
663 |
664 | [Download APK](apk/app-debug.apk) (on GitHub, click "View raw" to get the actual APK file)
665 |
666 |
--------------------------------------------------------------------------------
/androidmusicserverscreenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/androidmusicserverscreenshot.png
--------------------------------------------------------------------------------
/androidmusicserverscreenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/androidmusicserverscreenshot2.png
--------------------------------------------------------------------------------
/apk/app-debug.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/apk/app-debug.apk
--------------------------------------------------------------------------------
/apk/app-release.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/apk/app-release.apk
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | android {
4 | lintOptions {
5 | checkReleaseBuilds false
6 | abortOnError false
7 | }
8 | namespace 'net.kevinboone.androidmediaserver'
9 | compileSdkVersion 19
10 | buildToolsVersion "33.0.1"
11 |
12 | defaultConfig {
13 | applicationId "net.kevinboone.androidmediaserver"
14 | minSdkVersion 14
15 | targetSdkVersion 19
16 | }
17 |
18 | buildTypes {
19 | release {
20 | minifyEnabled false
21 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
16 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/default_cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/default_cover.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/functions.js:
--------------------------------------------------------------------------------
1 | var message_tick = 0;
2 |
3 | /*
4 | Called on loading main page
5 | */
6 | function onload ()
7 | {
8 | setInterval (tick, 5000);
9 | set_message ("Android music player ready");
10 | make_command_request ("status", response_callback_transport_status);
11 | }
12 |
13 | /*
14 | Called every 5 seconds by timer created at page load time
15 | */
16 | function tick()
17 | {
18 | message_tick++;
19 | if (message_tick >= 2)
20 | {
21 | set_message("");
22 | message_tick = 0;
23 | }
24 | make_command_request ("status", response_callback_transport_status);
25 | }
26 |
27 |
28 |
29 |
30 | function response_callback_transport_status (response_text)
31 | {
32 | var obj = eval ('(' + response_text + ')');
33 | set_transport_title (obj.title);
34 | set_transport_status (obj.transport_status);
35 | set_transport_position (msectominsec (obj.transport_position));
36 | set_transport_duration (msectominsec (obj.transport_duration));
37 | set_transport_artist (obj.artist);
38 | set_transport_album (obj.album);
39 | }
40 |
41 | /*
42 | Make an HTTP request on the specified uri, and call callback with
43 | the results when complete
44 | */
45 | function make_request (uri, callback)
46 | {
47 | var http_request = false;
48 | if (window.XMLHttpRequest)
49 | { // Mozilla, Safari, ...
50 | http_request = new XMLHttpRequest();
51 | if (http_request.overrideMimeType)
52 | {
53 | http_request.overrideMimeType('text/plain');
54 | }
55 | }
56 | else if (window.ActiveXObject)
57 | { // IE
58 | try
59 | {
60 | http_request = new ActiveXObject("Msxml2.XMLHTTP");
61 | }
62 | catch (e)
63 | {
64 | try
65 | {
66 | http_request = new ActiveXObject("Microsoft.XMLHTTP");
67 | }
68 | catch (e)
69 | {}
70 | }
71 | }
72 | if (!http_request)
73 | {
74 | alert('Giving up :( Cannot create an XMLHTTP instance');
75 | return false;
76 | }
77 | http_request.onreadystatechange = function()
78 | {
79 | do_http_request_complete (callback, http_request);
80 | };
81 | http_request.open('GET', uri, true);
82 | http_request.timeout = 10000; // Got to have _some_ value
83 | http_request.send(null);
84 | }
85 |
86 | /*
87 | do_http_request_complete
88 | Helper function for make_request
89 | */
90 | function do_http_request_complete (callback, http_request)
91 | {
92 | if (http_request.readyState == 4)
93 | {
94 | if (http_request.status == 200)
95 | {
96 | set_message ("");
97 | callback (http_request.responseText);
98 | }
99 | else
100 | {
101 | //alert('There was a problem with the request.');
102 | }
103 | }
104 | }
105 |
106 |
107 | /*
108 | stop() invoked from a link on the HTML page
109 | */
110 | function stop()
111 | {
112 | make_command_request ("stop", response_callback_gen_status);
113 | }
114 |
115 |
116 | /*
117 | play() invoked from a link on the HTML page
118 | */
119 | function play()
120 | {
121 | make_command_request ("play", response_callback_gen_status);
122 | }
123 |
124 |
125 | /*
126 | play_file_now(file) invoked from a link on the HTML page
127 | */
128 | function play_file_now(file)
129 | {
130 | make_command_request ("play_file_now " + file , response_callback_gen_status);
131 | }
132 |
133 |
134 | /*
135 | play_album_now(album) invoked from a link on the HTML page
136 | */
137 | function play_album_now(album)
138 | {
139 | make_command_request ("play_album_now " + album,
140 | response_callback_gen_status);
141 | }
142 |
143 |
144 | /*
145 | add_to_playlist(file) invoked from a link on the HTML page
146 | */
147 | function add_to_playlist(file)
148 | {
149 | make_command_request ("add_to_playlist " + file , response_callback_gen_status);
150 | }
151 |
152 |
153 | /*
154 | add_album_to_playlist(album) invoked from a link on the HTML page
155 | */
156 | function add_album_to_playlist(album)
157 | {
158 | make_command_request ("add_album_to_playlist " + album , response_callback_gen_status);
159 | }
160 |
161 |
162 |
163 | /*
164 | pause() invoked by a link on the HTML page
165 | */
166 | function pause()
167 | {
168 | make_command_request ("pause", response_callback_gen_status);
169 | }
170 |
171 |
172 | /*
173 | prev() invoked by a link on the HTML page
174 | */
175 | function prev()
176 | {
177 | make_command_request ("prev", response_callback_gen_status);
178 | }
179 |
180 |
181 | /*
182 | next() invoked by a link on the HTML page
183 | */
184 | function next()
185 | {
186 | make_command_request ("next", response_callback_gen_status);
187 | }
188 |
189 |
190 | /*
191 | clear_playlist() invoked by a link on the HTML page
192 | */
193 | function clear_playlist()
194 | {
195 | make_command_request ("clear_playlist", response_callback_gen_status);
196 | }
197 |
198 |
199 | /*
200 | rescan_catalog() invoked by a link on the HTML page
201 | */
202 | function rescan_catalog()
203 | {
204 | make_command_request ("rescan_catalog", response_callback_gen_status);
205 | }
206 |
207 |
208 | /*
209 | random_album() invoked by a link on the HTML page
210 | */
211 | function random_album()
212 | {
213 | make_command_request ("random_album", response_callback_gen_status);
214 | }
215 |
216 |
217 | /*
218 | shuffle_playlist() invoked by a link on the HTML page
219 | */
220 | function shuffle_playlist()
221 | {
222 | make_command_request ("shuffle_playlist", response_callback_gen_status);
223 | }
224 |
225 |
226 | /*
227 | rescan_filesystem() invoked by a link on the HTML page
228 | */
229 | function rescan_filesystem()
230 | {
231 | make_command_request ("rescan_filesystem", response_callback_gen_status);
232 | }
233 |
234 |
235 | /*
236 | volume_up() invoked by a link on the HTML page
237 | */
238 | function volume_up()
239 | {
240 | make_command_request ("volume_up", response_callback_gen_status);
241 | }
242 |
243 |
244 | /*
245 | enable_eq() invoked by a link on the HTML page or this file
246 | */
247 | function enable_eq()
248 | {
249 | make_command_request ("enable_eq", response_callback_gen_status);
250 | }
251 |
252 | /*
253 | disable_eq() invoked by a link on the HTML page or this file
254 | */
255 | function disable_eq()
256 | {
257 | make_command_request ("disable_eq", response_callback_gen_status);
258 | }
259 |
260 | /*
261 | enable_bass_boost() invoked by a link on the HTML page or this file
262 | */
263 | function enable_bass_boost()
264 | {
265 | make_command_request ("enable_bass_boost", response_callback_gen_status);
266 | }
267 |
268 |
269 | /*
270 | disable_bass_boost() invoked by a link on the HTML page or this file
271 | */
272 | function disable_bass_boost()
273 | {
274 | make_command_request ("disable_bass_boost", response_callback_gen_status);
275 | }
276 |
277 |
278 | /*
279 | volume_down() invoked by a link on the HTML page
280 | */
281 | function volume_down()
282 | {
283 | make_command_request ("volume_down", response_callback_gen_status);
284 | }
285 |
286 |
287 | /*
288 | set_eq_level () invoked by a link on the HTML page or this page
289 | */
290 | function set_eq_level(band, level)
291 | {
292 | make_command_request ("set_eq_level " + band + "," + level,
293 | response_callback_gen_status);
294 | }
295 |
296 |
297 | /*
298 | set_bb_level () invoked by a link on the HTML page or this page
299 | */
300 | function set_bb_level(level)
301 | {
302 | make_command_request ("set_bb_level " + level,
303 | response_callback_gen_status);
304 | }
305 |
306 |
307 | /*
308 | set_vol_level () invoked by a link on the HTML page or this page
309 | */
310 | function set_vol_level(level)
311 | {
312 | make_command_request ("set_vol_level " + level,
313 | response_callback_gen_status);
314 | }
315 |
316 |
317 |
318 | function make_command_request (cmd, callback)
319 | {
320 | self_uri = parse_uri (window.location.href);
321 |
322 |
323 | // The 'random' param is added to work around a stupid caching
324 | // bug in IE
325 | cmd_uri = "http://" + self_uri.host + ":" + self_uri.port +
326 | "/cmd?cmd=" + encodeURIComponent (cmd) + "&random=" + Math.random();
327 |
328 | make_request (cmd_uri, callback);
329 |
330 | //if (cmd != "get_transport_status")
331 | // set_message ("Communicating with server...");
332 | }
333 |
334 |
335 | /*
336 | A general callback to be attached to server commands that generate
337 | no specific response except a status message (play,
338 | add_to_playlist, etc)
339 | */
340 | function response_callback_gen_status (response_text)
341 | {
342 | var obj = eval ('(' + response_text + ')');
343 | set_message (obj.message);
344 | }
345 |
346 | /*
347 | The following functions just set text strings to page elements
348 | */
349 | function set_message (msg)
350 | {
351 | document.getElementById ("messagecell").innerHTML = msg;
352 | }
353 |
354 | function set_transport_uri (s)
355 | {
356 | document.getElementById ("transport_uri").innerHTML = s;
357 | }
358 |
359 | function set_transport_title (s)
360 | {
361 | document.getElementById ("transport_title").innerHTML = s;
362 | }
363 |
364 | function set_transport_album (s)
365 | {
366 | document.getElementById ("transport_album").innerHTML = s;
367 | }
368 |
369 | function set_transport_artist (s)
370 | {
371 | document.getElementById ("transport_artist").innerHTML = s;
372 | }
373 |
374 | function set_transport_status (s)
375 | {
376 | document.getElementById ("transport_status").innerHTML = s;
377 | }
378 |
379 | function set_transport_position (s)
380 | {
381 | document.getElementById ("transport_position").innerHTML = s;
382 | }
383 |
384 | function set_transport_duration (s)
385 | {
386 | document.getElementById ("transport_duration").innerHTML = s;
387 | }
388 |
389 | /*
390 | parse_uri
391 | Parse a uri into host, port, etc. Result are obatined in a structure
392 | */
393 | function parse_uri (str) {
394 | var o = parse_uri.options,
395 | m = o.parser[o.strictMode ? "strict" : "loose"].exec(str),
396 | uri = {},
397 | i = 14;
398 |
399 | while (i--) uri[o.key[i]] = m[i] || "";
400 |
401 | uri[o.q.name] = {};
402 | uri[o.key[12]].replace(o.q.parser, function ($0, $1, $2) {
403 | if ($1) uri[o.q.name][$1] = $2;
404 | });
405 |
406 | return uri;
407 | };
408 |
409 |
410 | parse_uri.options = {
411 | strictMode: false,
412 | key: ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"],
413 | q: {
414 | name: "queryKey",
415 | parser: /(?:^|&)([^&=]*)=?([^&]*)/g
416 | },
417 | parser: {
418 | strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,
419 | loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
420 | }
421 | };
422 |
423 |
424 | /*
425 | Convert a time in msec to min:sec
426 | */
427 | function msectominsec (msec)
428 | {
429 | var totalsec = Math.floor (msec / 1000);
430 | if (totalsec < 0) totalsec = 0; // Work around Xine bug
431 | var min = Math.floor (totalsec / 60);
432 | var sec = totalsec - min * 60;
433 | var smin = "" + min;
434 | if (min < 10) smin = "0" + smin;
435 | var ssec = "" + sec;
436 | if (sec < 10) ssec = "0" + ssec;
437 | return "" + smin + ":" + ssec;
438 | }
439 |
440 | /*
441 | Called when EQ is enabled or disabled on the gui_eq page
442 | */
443 | function onClickEqEnabled (cb)
444 | {
445 | if (cb.checked)
446 | enable_eq ();
447 | else
448 | disable_eq ();
449 | }
450 |
451 |
452 | /*
453 | Called when bass boost is enabled or disabled on the gui_eq page
454 | */
455 | function onClickBBEnabled (cb)
456 | {
457 | if (cb.checked)
458 | enable_bass_boost ();
459 | else
460 | disable_bass_boost ();
461 | }
462 |
463 |
464 | /*
465 | Called when an EQ slider is moved
466 | */
467 | function onChangeEqSlider (band, value)
468 | {
469 | set_eq_level (band, value);
470 | }
471 |
472 |
473 | /*
474 | Called when the BB slider is moved
475 | */
476 | function onChangeBBSlider (value)
477 | {
478 | set_bb_level (value);
479 | }
480 |
481 |
482 | /*
483 | Called when the Volume slider is moved
484 | */
485 | function onChangeVolSlider (value)
486 | {
487 | set_vol_level (value);
488 | }
489 |
490 |
491 | /* We have to go to prodigious lengths to get a delay in JS.
492 | In this case, we need to interpose a delay between executing a
493 | command on the server, and having the page refresh itself. */
494 | function refresh()
495 | {
496 | window.location.reload(true);
497 | }
498 |
499 |
500 | function delay_and_refresh()
501 | {
502 | setTimeout ("refresh()", 300);
503 | }
504 |
505 |
506 |
507 |
508 |
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/logo.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/nextbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/nextbutton.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/pausebutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/pausebutton.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/playbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/playbutton.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/prevbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/prevbutton.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/stopbutton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/stopbutton.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/styles.css:
--------------------------------------------------------------------------------
1 | body
2 | {
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | a:link
8 | {
9 | color: #4480b4;
10 | text-decoration: none;
11 | }
12 |
13 | a:visited
14 | {
15 | color: #4480b4;
16 | text-decoration: none;
17 | }
18 |
19 | a:hover
20 | {
21 | color: #cb8a48;
22 | text-decoration: none;
23 | }
24 |
25 | a:active
26 | {
27 | color: #cb8a48;
28 | text-decoration: none;
29 | }
30 |
31 | .bigtime
32 | {
33 | font-family: fixed;
34 | font-size: 200%;
35 | }
36 |
37 | .smalltime
38 | {
39 | font-family: fixed;
40 | }
41 |
42 | #logocell
43 | {
44 | padding-right: 20px;
45 | }
46 |
47 | #transportbuttoncell
48 | {
49 | min-width: 200px;
50 | }
51 |
52 | #trackinfotable
53 | {
54 | padding-left: 5px;
55 | }
56 |
57 | .textbuttonspan
58 | {
59 | font-weight: bold;
60 | font-family: sans-serif;
61 | font-size: smaller;
62 | }
63 |
64 | .pagetitle
65 | {
66 | font-weight: bold;
67 | font-family: sans-serif;
68 | font-size: larger;
69 | padding-bottom: 1em;
70 | }
71 |
72 | .pagesubtitle
73 | {
74 | font-weight: bold;
75 | font-family: sans-serif;
76 | padding-top: 1em;
77 | }
78 |
79 | .pagesubsubtitle
80 | {
81 | font-weight: bold;
82 | font-style: italic;
83 | padding-top: 1em;
84 | }
85 |
86 | .menutable
87 | {
88 | margin: 0;
89 | padding: 0;
90 | width: 100%;
91 | background: black;
92 | background-color: black;
93 | font-weight: bold;
94 | font-family: sans-serif;
95 | }
96 |
97 |
98 | .maincontent
99 | {
100 | margin-left: 2em;
101 | }
102 |
103 |
104 | #searchform
105 | {
106 | padding: 0;
107 | margin: 0;
108 | text-align: right;
109 | color: #4480b4;
110 | font-size: smaller;
111 | font-family: sans-serif;
112 | }
113 |
114 | #searchform input
115 | {
116 | background-color: #444444;
117 | color: #4480b4;
118 | border: solid black;
119 | }
120 |
121 |
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/vol_down.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/vol_down.png
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/vol_up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/vol_up.png
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/AndroidNetworkUtil.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 | package net.kevinboone.androidmediaserver;
7 |
8 | import android.net.wifi.*;
9 | import android.content.Context;
10 | import android.text.format.Formatter;
11 | import java.io.*;
12 |
13 | public class AndroidNetworkUtil
14 | {
15 | /** Try to get the WIFI IP. Returns null if there is not one. I don' t know
16 | how relaible this is, to be honest. */
17 | public static String getWifiIP (Context c)
18 | {
19 | WifiManager wifiMgr = (WifiManager)
20 | c.getApplicationContext().getSystemService (Context.WIFI_SERVICE);
21 | WifiInfo wifiInfo = wifiMgr.getConnectionInfo();
22 | if (wifiInfo != null)
23 | {
24 | int ip = wifiInfo.getIpAddress();
25 | if (ip == 0) return null;
26 | return Formatter.formatIpAddress(ip);
27 | }
28 | else
29 | return null;
30 | }
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/CoverUtils.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmediaserver;
8 | import java.util.*;
9 | import java.text.*;
10 | import java.io.*;
11 | import java.net.*;
12 |
13 | public class CoverUtils
14 | {
15 | public static String getCoverFileForTrackFile (String filePath)
16 | {
17 | if (filePath.startsWith ("file://"))
18 | filePath = filePath.substring (7);
19 |
20 | int p = filePath.lastIndexOf ('/');
21 | if (p <= 0) return null;
22 |
23 | filePath = filePath.substring (0, p);
24 |
25 | String cand = makeFile (filePath, "folder.jpg");
26 | if (doesFileExist (cand)) return cand;
27 | cand = makeFile (filePath, "folder.png");
28 | if (doesFileExist (cand)) return cand;
29 | cand = makeFile (filePath, "cover.jpg");
30 | if (doesFileExist (cand)) return cand;
31 | cand = makeFile (filePath, "cover.png");
32 | if (doesFileExist (cand)) return cand;
33 | cand = makeFile (filePath, ".folder.png");
34 | if (doesFileExist (cand)) return cand;
35 | cand = makeFile (filePath, ".folder.jpg");
36 | if (doesFileExist (cand)) return cand;
37 |
38 | return null;
39 | }
40 |
41 | private static String makeFile (String dir, String name)
42 | {
43 | return dir + "/" + name;
44 | }
45 |
46 | private static boolean doesFileExist (String filePath)
47 | {
48 | File f = new File (filePath);
49 | if (f.isFile()) return true;
50 | return false;
51 | }
52 |
53 | }
54 |
55 |
56 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/FileUtils.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmediaserver;
8 | import java.util.Locale;
9 |
10 | public class FileUtils
11 | {
12 | public static String getMimeType (String filename)
13 | {
14 | String lc = filename.toLowerCase(Locale.getDefault());
15 | if (lc.endsWith (".jpg"))
16 | return "image/jpeg";
17 | if (lc.endsWith (".png"))
18 | return "image/png";
19 | if (lc.endsWith (".gif"))
20 | return "image/gif";
21 | return "application/octet-stream";
22 | }
23 | }
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/Main.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 | package net.kevinboone.androidmediaserver;
7 |
8 | import android.app.Activity;
9 | import android.util.Log;
10 | import android.os.*;
11 | import android.view.*;
12 | import android.widget.*;
13 | import android.media.*;
14 | import android.content.*;
15 | import java.io.*;
16 | import java.net.*;
17 | import android.preference.*;
18 | import net.kevinboone.androidmediaserver.client.*;
19 |
20 | /** This class contains the Android user interface, such as it is. */
21 | public class Main extends Activity
22 | {
23 | private Handler handler = new Handler();
24 | // Ugly, but we need the port number to be accessible to the background
25 | // service, and there is no easy way to pass arguments to it
26 | protected static int port = 30000;
27 | protected int uiUpdateInterval = 5000; // msec
28 | protected int webUpdateInterval = 5000; // msec
29 | protected static int tracksPerPage = 30;
30 | protected static int maxSearchResults = 20;
31 |
32 |
33 | /*An arbitrary value to distinguish completion of the Settings activity
34 | from any other activity (there are none, at present) */
35 | private static final int RESULT_SETTINGS = 0;
36 |
37 | TextView titleView;
38 | TextView albumView;
39 | TextView artistView;
40 | TextView transportStatusView;
41 |
42 | TextView messageView;
43 |
44 | /* Define a runnable for use with the Handler, that will
45 | update the UI on the main thread every 5 seconds */
46 | private Runnable updateUITask = new Runnable()
47 | {
48 | public void run()
49 | {
50 | messageView.setText ("");
51 | updateUI();
52 | handler.postDelayed (this, uiUpdateInterval);
53 | }
54 | };
55 |
56 | /**
57 | Update the UI from the server monitoring thread.
58 | */
59 | public void updateUI()
60 | {
61 | try
62 | {
63 | Client client = new Client ("localhost", port);
64 | Status status = client.getStatus();
65 | titleView.setText (status.getTitle());
66 | albumView.setText (status.getAlbum());
67 | artistView.setText (status.getArtist());
68 | transportStatusView.setText
69 | (status.transportStatusToString (status.getTransportStatus()));
70 | }
71 | catch (ConnectException e)
72 | {
73 | /* This is probably the user's only indication that the web server
74 | did not initialize. */
75 | Log.e ("AMS", e.toString());
76 | messageView.setText
77 | ("Can't connect to service: check server port number and restart");
78 | }
79 | catch (Exception e)
80 | {
81 | Log.e ("AMS", e.toString());
82 | messageView.setText (e.toString());
83 | }
84 | }
85 |
86 | @Override
87 | public void onCreate (Bundle savedInstanceState)
88 | {
89 | super.onCreate(savedInstanceState);
90 |
91 | /*These lines allow network operations on the main thread. In nearly
92 | all cases this is a bad idea, but here the network operation is
93 | to a different thread in this same application, and should never
94 | block. If it does block, the app's hosed anyway, so this won't
95 | make it worse. */
96 | StrictMode.ThreadPolicy policy =
97 | new StrictMode.ThreadPolicy.Builder().permitAll().build();
98 | StrictMode.setThreadPolicy(policy);
99 |
100 | applySettings();
101 |
102 | setContentView(R.layout.main);
103 | setVolumeControlStream(AudioManager.STREAM_MUSIC);
104 | TextView urlView = (TextView) findViewById (R.id.url);
105 | titleView = (TextView) findViewById (R.id.title);
106 | messageView = (TextView) findViewById (R.id.message);
107 | artistView = (TextView) findViewById (R.id.artist);
108 | albumView = (TextView) findViewById (R.id.album);
109 | transportStatusView = (TextView) findViewById (R.id.transport_status);
110 |
111 | String ip = AndroidNetworkUtil.getWifiIP(this);
112 | if (ip != null)
113 | {
114 | try
115 | {
116 | startBackgroundService();
117 | urlView.setText ("http://" + ip + ":" + port + "/");
118 |
119 | // If we get here, with luck the server is running
120 |
121 | handler.removeCallbacks (updateUITask);
122 | handler.postDelayed (updateUITask, uiUpdateInterval);
123 | }
124 | catch (Throwable e)
125 | {
126 | messageView.setText ("Error: " + e.getMessage());
127 | }
128 | }
129 | else
130 | {
131 | messageView.setText ("No WIFI connection?");
132 | }
133 | }
134 |
135 | @Override
136 | public void onDestroy()
137 | {
138 | stopBackgroundService();
139 | super.onDestroy();
140 | }
141 |
142 |
143 | public void stopBackgroundService ()
144 | {
145 | Intent intent = new Intent (this, WebPlayerService.class);
146 | stopService (intent);
147 | }
148 |
149 |
150 | public void startBackgroundService ()
151 | {
152 | Intent intent = new Intent (this, WebPlayerService.class);
153 | startService (intent);
154 | }
155 |
156 |
157 | public void buttonSettingsClicked(View dummy)
158 | {
159 | Intent i = new Intent (this, SettingsActivity.class);
160 | startActivityForResult(i, RESULT_SETTINGS);
161 | }
162 |
163 |
164 | public void buttonShutdownClicked(View dummy)
165 | {
166 | finish();
167 | }
168 |
169 |
170 | public void buttonPlayClicked(View dummy)
171 | {
172 | play();
173 | updateUI();
174 | }
175 |
176 |
177 | public void buttonPauseClicked(View dummy)
178 | {
179 | pause();
180 | updateUI();
181 | }
182 |
183 |
184 | public void buttonNextClicked(View dummy)
185 | {
186 | next();
187 | updateUI();
188 | }
189 |
190 |
191 | public void buttonPrevClicked(View dummy)
192 | {
193 | prev();
194 | updateUI();
195 | }
196 |
197 |
198 | public void buttonStopClicked(View dummy)
199 | {
200 | stop();
201 | updateUI();
202 | }
203 |
204 |
205 | public void play()
206 | {
207 | Client client = new Client ("localhost", port);
208 | try
209 | {
210 | client.play();
211 | }
212 | catch (Exception e)
213 | {
214 | messageView.setText (e.getMessage());
215 | }
216 | }
217 |
218 |
219 | public void pause()
220 | {
221 | Client client = new Client ("localhost", port);
222 | try
223 | {
224 | client.pause();
225 | }
226 | catch (Exception e)
227 | {
228 | messageView.setText (e.getMessage());
229 | }
230 | }
231 |
232 |
233 | public void next()
234 | {
235 | Client client = new Client ("localhost", port);
236 | try
237 | {
238 | client.next();
239 | }
240 | catch (Exception e)
241 | {
242 | messageView.setText (e.getMessage());
243 | }
244 | }
245 |
246 |
247 | public void prev()
248 | {
249 | Client client = new Client ("localhost", port);
250 | try
251 | {
252 | client.prev();
253 | }
254 | catch (Exception e)
255 | {
256 | messageView.setText (e.getMessage());
257 | }
258 | }
259 |
260 |
261 | public void stop()
262 | {
263 | Client client = new Client ("localhost", port);
264 | try
265 | {
266 | client.stop();
267 | }
268 | catch (Exception e)
269 | {
270 | messageView.setText (e.getMessage());
271 | }
272 | }
273 |
274 | @Override
275 | protected void onActivityResult (int requestCode,
276 | int resultCode, Intent data)
277 | {
278 | super.onActivityResult (requestCode, resultCode, data);
279 | switch (requestCode)
280 | {
281 | case RESULT_SETTINGS:
282 | applySettings ();
283 | break;
284 | }
285 | }
286 |
287 |
288 | private void applySettings ()
289 | {
290 | uiUpdateInterval = getIntPreference ("uiupdateinterval", 5) * 1000;
291 | maxSearchResults = getIntPreference ("maxsearchresults", 20);
292 | tracksPerPage = getIntPreference ("tracksperpage", 30);
293 | webUpdateInterval = getIntPreference ("webupdateinterval", 5) * 1000;
294 | int newPort = getIntPreference ("port", 30000);
295 | if (newPort != port)
296 | {
297 | port = newPort;
298 | Log.w ("AMS", "Changing port number to " + port);
299 | stopBackgroundService();
300 | startBackgroundService();
301 | }
302 | }
303 |
304 | /** Wrapper around Android's brain-dead (non-)handling of integer-valued
305 | * user preferences :/. */
306 | int getIntPreference (String name, int deflt)
307 | {
308 | SharedPreferences sharedPrefs =
309 | PreferenceManager.getDefaultSharedPreferences (this);
310 |
311 | int value = deflt;
312 | try
313 | {
314 | value =
315 | Integer.parseInt (sharedPrefs.getString (name, "" + deflt));
316 | }
317 | catch (Exception e)
318 | {
319 | value = deflt;
320 | }
321 | return value;
322 | }
323 |
324 |
325 |
326 | }
327 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/NanoHTTPD.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver;
2 |
3 | import java.io.*;
4 | import java.net.InetAddress;
5 | import java.net.InetSocketAddress;
6 | import java.net.ServerSocket;
7 | import java.net.Socket;
8 | import java.net.SocketException;
9 | import java.net.SocketTimeoutException;
10 | import java.net.URLDecoder;
11 | import java.nio.ByteBuffer;
12 | import java.nio.channels.FileChannel;
13 | import java.text.SimpleDateFormat;
14 | import java.util.ArrayList;
15 | import java.util.Calendar;
16 | import java.util.Date;
17 | import java.util.HashMap;
18 | import java.util.HashSet;
19 | import java.util.Iterator;
20 | import java.util.List;
21 | import java.util.Locale;
22 | import java.util.Map;
23 | import java.util.Set;
24 | import java.util.StringTokenizer;
25 | import java.util.TimeZone;
26 |
27 | /**
28 | * A simple, tiny, nicely embeddable HTTP server in Java
29 | *
30 | *
31 | * NanoHTTPD
32 | * Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias
33 | *
34 | *
35 | * Features + limitations:
36 | *
37 | *
38 | *
Only one Java file
39 | *
Java 5 compatible
40 | *
Released as open source, Modified BSD licence
41 | *
No fixed config files, logging, authorization etc. (Implement yourself if you need them.)
42 | *
Supports parameter parsing of GET and POST methods (+ rudimentary PUT support in 1.25)
43 | *
Supports both dynamic content and file serving
44 | *
Supports file upload (since version 1.2, 2010)
45 | *
Supports partial content (streaming)
46 | *
Supports ETags
47 | *
Never caches anything
48 | *
Doesn't limit bandwidth, request time or simultaneous connections
49 | *
Default code serves files and shows all HTTP parameters and headers
50 | *
File server supports directory listing, index.html and index.htm
51 | *
File server supports partial content (streaming)
52 | *
File server supports ETags
53 | *
File server does the 301 redirection trick for directories without '/'
54 | *
File server supports simple skipping for files (continue download)
55 | *
File server serves also very long files without memory overhead
56 | *
Contains a built-in list of most common mime types
57 | *
All header names are converted lowercase so they don't vary between browsers/clients
58 | *
59 | *
60 | *
61 | *
62 | * How to use:
63 | *
64 | *
65 | *
Subclass and implement serve() and embed to your own program
66 | *
67 | *
68 | *
69 | * See the separate "LICENSE.md" file for the distribution license (Modified BSD licence)
70 | */
71 | public abstract class NanoHTTPD {
72 | /**
73 | * Maximum time to wait on Socket.getInputStream().read() (in milliseconds)
74 | * This is required as the Keep-Alive HTTP connections would otherwise
75 | * block the socket reading thread forever (or as long the browser is open).
76 | */
77 | public static final int SOCKET_READ_TIMEOUT = 5000;
78 | /**
79 | * Common mime type for dynamic content: plain text
80 | */
81 | public static final String MIME_PLAINTEXT = "text/plain";
82 | /**
83 | * Common mime type for dynamic content: html
84 | */
85 | public static final String MIME_HTML = "text/html";
86 | /**
87 | * Pseudo-Parameter to use to store the actual query string in the parameters map for later re-processing.
88 | */
89 | private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING";
90 | private final String hostname;
91 | private final int myPort;
92 | private ServerSocket myServerSocket;
93 | private Set openConnections = new HashSet();
94 | private Thread myThread;
95 | /**
96 | * Pluggable strategy for asynchronously executing requests.
97 | */
98 | private AsyncRunner asyncRunner;
99 | /**
100 | * Pluggable strategy for creating and cleaning up temporary files.
101 | */
102 | private TempFileManagerFactory tempFileManagerFactory;
103 |
104 | /**
105 | * Constructs an HTTP server on given port.
106 | */
107 | public NanoHTTPD(int port) {
108 | this(null, port);
109 | }
110 |
111 | /**
112 | * Constructs an HTTP server on given hostname and port.
113 | */
114 | public NanoHTTPD(String hostname, int port) {
115 | this.hostname = hostname;
116 | this.myPort = port;
117 | setTempFileManagerFactory(new DefaultTempFileManagerFactory());
118 | setAsyncRunner(new DefaultAsyncRunner());
119 | }
120 |
121 | private static final void safeClose(Closeable closeable) {
122 | if (closeable != null) {
123 | try {
124 | closeable.close();
125 | } catch (IOException e) {
126 | }
127 | }
128 | }
129 |
130 | private static final void safeClose(Socket closeable) {
131 | if (closeable != null) {
132 | try {
133 | closeable.close();
134 | } catch (IOException e) {
135 | }
136 | }
137 | }
138 |
139 | private static final void safeClose(ServerSocket closeable) {
140 | if (closeable != null) {
141 | try {
142 | closeable.close();
143 | } catch (IOException e) {
144 | }
145 | }
146 | }
147 |
148 | /**
149 | * Start the server.
150 | *
151 | * @throws IOException if the socket is in use.
152 | */
153 | public void start() throws IOException {
154 | myServerSocket = new ServerSocket();
155 | myServerSocket.bind((hostname != null) ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort));
156 |
157 | myThread = new Thread(new Runnable() {
158 | @Override
159 | public void run() {
160 | do {
161 | try {
162 | final Socket finalAccept = myServerSocket.accept();
163 | registerConnection(finalAccept);
164 | finalAccept.setSoTimeout(SOCKET_READ_TIMEOUT);
165 | final InputStream inputStream = finalAccept.getInputStream();
166 | asyncRunner.exec(new Runnable() {
167 | @Override
168 | public void run() {
169 | OutputStream outputStream = null;
170 | try {
171 | outputStream = finalAccept.getOutputStream();
172 | TempFileManager tempFileManager = tempFileManagerFactory.create();
173 | HTTPSession session = new HTTPSession(tempFileManager, inputStream, outputStream, finalAccept.getInetAddress());
174 | while (!finalAccept.isClosed()) {
175 | session.execute();
176 | }
177 | } catch (Exception e) {
178 | // When the socket is closed by the client, we throw our own SocketException
179 | // to break the "keep alive" loop above.
180 | if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage()))) {
181 | e.printStackTrace();
182 | }
183 | } finally {
184 | safeClose(outputStream);
185 | safeClose(inputStream);
186 | safeClose(finalAccept);
187 | unRegisterConnection(finalAccept);
188 | }
189 | }
190 | });
191 | } catch (IOException e) {
192 | }
193 | } while (!myServerSocket.isClosed());
194 | }
195 | });
196 | myThread.setDaemon(true);
197 | myThread.setName("NanoHttpd Main Listener");
198 | myThread.start();
199 | }
200 |
201 | /**
202 | * Stop the server.
203 | */
204 | public void stop() {
205 | try {
206 | safeClose(myServerSocket);
207 | closeAllConnections();
208 | if (myThread != null) {
209 | myThread.join();
210 | }
211 | } catch (Exception e) {
212 | e.printStackTrace();
213 | }
214 | }
215 |
216 | /**
217 | * Registers that a new connection has been set up.
218 | *
219 | * @param socket the {@link Socket} for the connection.
220 | */
221 | public synchronized void registerConnection(Socket socket) {
222 | openConnections.add(socket);
223 | }
224 |
225 | /**
226 | * Registers that a connection has been closed
227 | *
228 | * @param socket
229 | * the {@link Socket} for the connection.
230 | */
231 | public synchronized void unRegisterConnection(Socket socket) {
232 | openConnections.remove(socket);
233 | }
234 |
235 | /**
236 | * Forcibly closes all connections that are open.
237 | */
238 | public synchronized void closeAllConnections() {
239 | for (Socket socket : openConnections) {
240 | safeClose(socket);
241 | }
242 | }
243 |
244 | public final int getListeningPort() {
245 | return myServerSocket == null ? -1 : myServerSocket.getLocalPort();
246 | }
247 |
248 | public final boolean wasStarted() {
249 | return myServerSocket != null && myThread != null;
250 | }
251 |
252 | public final boolean isAlive() {
253 | return wasStarted() && !myServerSocket.isClosed() && myThread.isAlive();
254 | }
255 |
256 | /**
257 | * Override this to customize the server.
258 | *
259 | *
260 | * (By default, this delegates to serveFile() and allows directory listing.)
261 | *
262 | * @param uri Percent-decoded URI without parameters, for example "/index.cgi"
263 | * @param method "GET", "POST" etc.
264 | * @param parms Parsed, percent decoded parameters from URI and, in case of POST, data.
265 | * @param headers Header entries, percent decoded
266 | * @return HTTP response, see class Response for details
267 | */
268 | @Deprecated
269 | public Response serve(String uri, Method method, Map headers, Map parms,
270 | Map files) {
271 | return new Response(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found");
272 | }
273 |
274 | /**
275 | * Override this to customize the server.
276 | *
277 | *
278 | * (By default, this delegates to serveFile() and allows directory listing.)
279 | *
280 | * @param session The HTTP session
281 | * @return HTTP response, see class Response for details
282 | */
283 | public Response serve(IHTTPSession session) {
284 | Map files = new HashMap();
285 | Method method = session.getMethod();
286 | if (Method.PUT.equals(method) || Method.POST.equals(method)) {
287 | try {
288 | session.parseBody(files);
289 | } catch (IOException ioe) {
290 | return new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
291 | } catch (ResponseException re) {
292 | return new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
293 | }
294 | }
295 |
296 | Map parms = session.getParms();
297 | parms.put(QUERY_STRING_PARAMETER, session.getQueryParameterString());
298 | return serve(session.getUri(), method, session.getHeaders(), parms, files);
299 | }
300 |
301 | /**
302 | * Decode percent encoded String values.
303 | *
304 | * @param str the percent encoded String
305 | * @return expanded form of the input, for example "foo%20bar" becomes "foo bar"
306 | */
307 | protected String decodePercent(String str) {
308 | String decoded = null;
309 | try {
310 | decoded = URLDecoder.decode(str, "UTF8");
311 | } catch (UnsupportedEncodingException ignored) {
312 | }
313 | return decoded;
314 | }
315 |
316 | /**
317 | * Decode parameters from a URL, handing the case where a single parameter name might have been
318 | * supplied several times, by return lists of values. In general these lists will contain a single
319 | * element.
320 | *
321 | * @param parms original NanoHttpd parameters values, as passed to the serve() method.
322 | * @return a map of String (parameter name) to List<String> (a list of the values supplied).
323 | */
324 | protected Map> decodeParameters(Map parms) {
325 | return this.decodeParameters(parms.get(QUERY_STRING_PARAMETER));
326 | }
327 |
328 | /**
329 | * Decode parameters from a URL, handing the case where a single parameter name might have been
330 | * supplied several times, by return lists of values. In general these lists will contain a single
331 | * element.
332 | *
333 | * @param queryString a query string pulled from the URL.
334 | * @return a map of String (parameter name) to List<String> (a list of the values supplied).
335 | */
336 | protected Map> decodeParameters(String queryString) {
337 | Map> parms = new HashMap>();
338 | if (queryString != null) {
339 | StringTokenizer st = new StringTokenizer(queryString, "&");
340 | while (st.hasMoreTokens()) {
341 | String e = st.nextToken();
342 | int sep = e.indexOf('=');
343 | String propertyName = (sep >= 0) ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim();
344 | if (!parms.containsKey(propertyName)) {
345 | parms.put(propertyName, new ArrayList());
346 | }
347 | String propertyValue = (sep >= 0) ? decodePercent(e.substring(sep + 1)) : null;
348 | if (propertyValue != null) {
349 | parms.get(propertyName).add(propertyValue);
350 | }
351 | }
352 | }
353 | return parms;
354 | }
355 |
356 | // ------------------------------------------------------------------------------- //
357 | //
358 | // Threading Strategy.
359 | //
360 | // ------------------------------------------------------------------------------- //
361 |
362 | /**
363 | * Pluggable strategy for asynchronously executing requests.
364 | *
365 | * @param asyncRunner new strategy for handling threads.
366 | */
367 | public void setAsyncRunner(AsyncRunner asyncRunner) {
368 | this.asyncRunner = asyncRunner;
369 | }
370 |
371 | // ------------------------------------------------------------------------------- //
372 | //
373 | // Temp file handling strategy.
374 | //
375 | // ------------------------------------------------------------------------------- //
376 |
377 | /**
378 | * Pluggable strategy for creating and cleaning up temporary files.
379 | *
380 | * @param tempFileManagerFactory new strategy for handling temp files.
381 | */
382 | public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) {
383 | this.tempFileManagerFactory = tempFileManagerFactory;
384 | }
385 |
386 | /**
387 | * HTTP Request methods, with the ability to decode a String back to its enum value.
388 | */
389 | public enum Method {
390 | GET, PUT, POST, DELETE, HEAD, OPTIONS;
391 |
392 | static Method lookup(String method) {
393 | for (Method m : Method.values()) {
394 | if (m.toString().equalsIgnoreCase(method)) {
395 | return m;
396 | }
397 | }
398 | return null;
399 | }
400 | }
401 |
402 | /**
403 | * Pluggable strategy for asynchronously executing requests.
404 | */
405 | public interface AsyncRunner {
406 | void exec(Runnable code);
407 | }
408 |
409 | /**
410 | * Factory to create temp file managers.
411 | */
412 | public interface TempFileManagerFactory {
413 | TempFileManager create();
414 | }
415 |
416 | // ------------------------------------------------------------------------------- //
417 |
418 | /**
419 | * Temp file manager.
420 | *
421 | *
Temp file managers are created 1-to-1 with incoming requests, to create and cleanup
422 | * temporary files created as a result of handling the request.
By default, the server spawns a new Thread for every incoming request. These are set
448 | * to daemon status, and named according to the request number. The name is
449 | * useful when profiling the application.
450 | */
451 | public static class DefaultAsyncRunner implements AsyncRunner {
452 | private long requestCount;
453 |
454 | @Override
455 | public void exec(Runnable code) {
456 | ++requestCount;
457 | Thread t = new Thread(code);
458 | t.setDaemon(true);
459 | t.setName("NanoHttpd Request Processor (#" + requestCount + ")");
460 | t.start();
461 | }
462 | }
463 |
464 | /**
465 | * Default strategy for creating and cleaning up temporary files.
466 | *
467 | * This class stores its files in the standard location (that is,
468 | * wherever java.io.tmpdir points to). Files are added
469 | * to an internal list, and deleted when no longer needed (that is,
470 | * when clear() is invoked at the end of processing a
471 | * request).
472 | */
473 | public static class DefaultTempFileManager implements TempFileManager {
474 | private final String tmpdir;
475 | private final List tempFiles;
476 |
477 | public DefaultTempFileManager() {
478 | tmpdir = System.getProperty("java.io.tmpdir");
479 | tempFiles = new ArrayList();
480 | }
481 |
482 | @Override
483 | public TempFile createTempFile() throws Exception {
484 | DefaultTempFile tempFile = new DefaultTempFile(tmpdir);
485 | tempFiles.add(tempFile);
486 | return tempFile;
487 | }
488 |
489 | @Override
490 | public void clear() {
491 | for (TempFile file : tempFiles) {
492 | try {
493 | file.delete();
494 | } catch (Exception ignored) {
495 | }
496 | }
497 | tempFiles.clear();
498 | }
499 | }
500 |
501 | /**
502 | * Default strategy for creating and cleaning up temporary files.
503 | *
504 | * [>By default, files are created by File.createTempFile() in
505 | * the directory specified.
506 | */
507 | public static class DefaultTempFile implements TempFile {
508 | private File file;
509 | private OutputStream fstream;
510 |
511 | public DefaultTempFile(String tempdir) throws IOException {
512 | file = File.createTempFile("NanoHTTPD-", "", new File(tempdir));
513 | fstream = new FileOutputStream(file);
514 | }
515 |
516 | @Override
517 | public OutputStream open() throws Exception {
518 | return fstream;
519 | }
520 |
521 | @Override
522 | public void delete() throws Exception {
523 | safeClose(fstream);
524 | file.delete();
525 | }
526 |
527 | @Override
528 | public String getName() {
529 | return file.getAbsolutePath();
530 | }
531 | }
532 |
533 | /**
534 | * HTTP response. Return one of these from serve().
535 | */
536 | public static class Response {
537 | /**
538 | * HTTP status code after processing, e.g. "200 OK", HTTP_OK
539 | */
540 | private IStatus status;
541 | /**
542 | * MIME type of content, e.g. "text/html"
543 | */
544 | private String mimeType;
545 | /**
546 | * Data of the response, may be null.
547 | */
548 | private InputStream data;
549 | /**
550 | * Headers for the HTTP response. Use addHeader() to add lines.
551 | */
552 | private Map header = new HashMap();
553 | /**
554 | * The request method that spawned this response.
555 | */
556 | private Method requestMethod;
557 | /**
558 | * Use chunkedTransfer
559 | */
560 | private boolean chunkedTransfer;
561 |
562 | /**
563 | * Default constructor: response = HTTP_OK, mime = MIME_HTML and your supplied message
564 | */
565 | public Response(String msg) {
566 | this(Status.OK, MIME_HTML, msg);
567 | }
568 |
569 | /**
570 | * Basic constructor.
571 | */
572 | public Response(IStatus status, String mimeType, InputStream data) {
573 | this.status = status;
574 | this.mimeType = mimeType;
575 | this.data = data;
576 | }
577 |
578 | /**
579 | * Convenience method that makes an InputStream out of given text.
580 | */
581 | public Response(IStatus status, String mimeType, String txt) {
582 | this.status = status;
583 | this.mimeType = mimeType;
584 | try {
585 | this.data = txt != null ? new ByteArrayInputStream(txt.getBytes("UTF-8")) : null;
586 | } catch (java.io.UnsupportedEncodingException uee) {
587 | uee.printStackTrace();
588 | }
589 | }
590 |
591 | /**
592 | * Adds given line to the header.
593 | */
594 | public void addHeader(String name, String value) {
595 | header.put(name, value);
596 | }
597 |
598 | public String getHeader(String name) {
599 | return header.get(name);
600 | }
601 |
602 | /**
603 | * Sends given response to the socket.
604 | */
605 | protected void send(OutputStream outputStream) {
606 | String mime = mimeType;
607 | SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
608 | gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT"));
609 |
610 | try {
611 | if (status == null) {
612 | throw new Error("sendResponse(): Status can't be null.");
613 | }
614 | PrintWriter pw = new PrintWriter(outputStream);
615 | pw.print("HTTP/1.1 " + status.getDescription() + " \r\n");
616 |
617 | if (mime != null) {
618 | pw.print("Content-Type: " + mime + "\r\n");
619 | }
620 |
621 | if (header == null || header.get("Date") == null) {
622 | pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n");
623 | }
624 |
625 | if (header != null) {
626 | for (String key : header.keySet()) {
627 | String value = header.get(key);
628 | pw.print(key + ": " + value + "\r\n");
629 | }
630 | }
631 |
632 | sendConnectionHeaderIfNotAlreadyPresent(pw, header);
633 |
634 | if (requestMethod != Method.HEAD && chunkedTransfer) {
635 | sendAsChunked(outputStream, pw);
636 | } else {
637 | int pending = data != null ? data.available() : 0;
638 | sendContentLengthHeaderIfNotAlreadyPresent(pw, header, pending);
639 | pw.print("\r\n");
640 | pw.flush();
641 | sendAsFixedLength(outputStream, pending);
642 | }
643 | outputStream.flush();
644 | safeClose(data);
645 | } catch (IOException ioe) {
646 | // Couldn't write? No can do.
647 | }
648 | }
649 |
650 | protected void sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map header, int size) {
651 | if (!headerAlreadySent(header, "content-length")) {
652 | pw.print("Content-Length: "+ size +"\r\n");
653 | }
654 | }
655 |
656 | protected void sendConnectionHeaderIfNotAlreadyPresent(PrintWriter pw, Map header) {
657 | if (!headerAlreadySent(header, "connection")) {
658 | pw.print("Connection: keep-alive\r\n");
659 | }
660 | }
661 |
662 | private boolean headerAlreadySent(Map header, String name) {
663 | boolean alreadySent = false;
664 | for (String headerName : header.keySet()) {
665 | alreadySent |= headerName.equalsIgnoreCase(name);
666 | }
667 | return alreadySent;
668 | }
669 |
670 | private void sendAsChunked(OutputStream outputStream, PrintWriter pw) throws IOException {
671 | pw.print("Transfer-Encoding: chunked\r\n");
672 | pw.print("\r\n");
673 | pw.flush();
674 | int BUFFER_SIZE = 16 * 1024;
675 | byte[] CRLF = "\r\n".getBytes();
676 | byte[] buff = new byte[BUFFER_SIZE];
677 | int read;
678 | while ((read = data.read(buff)) > 0) {
679 | outputStream.write(String.format("%x\r\n", read).getBytes());
680 | outputStream.write(buff, 0, read);
681 | outputStream.write(CRLF);
682 | }
683 | outputStream.write(String.format("0\r\n\r\n").getBytes());
684 | }
685 |
686 | private void sendAsFixedLength(OutputStream outputStream, int pending) throws IOException {
687 | if (requestMethod != Method.HEAD && data != null) {
688 | int BUFFER_SIZE = 16 * 1024;
689 | byte[] buff = new byte[BUFFER_SIZE];
690 | while (pending > 0) {
691 | int read = data.read(buff, 0, ((pending > BUFFER_SIZE) ? BUFFER_SIZE : pending));
692 | if (read <= 0) {
693 | break;
694 | }
695 | outputStream.write(buff, 0, read);
696 | pending -= read;
697 | }
698 | }
699 | }
700 |
701 | public IStatus getStatus() {
702 | return status;
703 | }
704 |
705 | public void setStatus(Status status) {
706 | this.status = status;
707 | }
708 |
709 | public String getMimeType() {
710 | return mimeType;
711 | }
712 |
713 | public void setMimeType(String mimeType) {
714 | this.mimeType = mimeType;
715 | }
716 |
717 | public InputStream getData() {
718 | return data;
719 | }
720 |
721 | public void setData(InputStream data) {
722 | this.data = data;
723 | }
724 |
725 | public Method getRequestMethod() {
726 | return requestMethod;
727 | }
728 |
729 | public void setRequestMethod(Method requestMethod) {
730 | this.requestMethod = requestMethod;
731 | }
732 |
733 | public void setChunkedTransfer(boolean chunkedTransfer) {
734 | this.chunkedTransfer = chunkedTransfer;
735 | }
736 |
737 | public interface IStatus {
738 | int getRequestStatus();
739 | String getDescription();
740 | }
741 |
742 | /**
743 | * Some HTTP response status codes
744 | */
745 | public enum Status implements IStatus {
746 | SWITCH_PROTOCOL(101, "Switching Protocols"), OK(200, "OK"), CREATED(201, "Created"), ACCEPTED(202, "Accepted"), NO_CONTENT(204, "No Content"), PARTIAL_CONTENT(206, "Partial Content"), REDIRECT(301,
747 | "Moved Permanently"), NOT_MODIFIED(304, "Not Modified"), BAD_REQUEST(400, "Bad Request"), UNAUTHORIZED(401,
748 | "Unauthorized"), FORBIDDEN(403, "Forbidden"), NOT_FOUND(404, "Not Found"), METHOD_NOT_ALLOWED(405, "Method Not Allowed"), RANGE_NOT_SATISFIABLE(416,
749 | "Requested Range Not Satisfiable"), INTERNAL_ERROR(500, "Internal Server Error");
750 | private final int requestStatus;
751 | private final String description;
752 |
753 | Status(int requestStatus, String description) {
754 | this.requestStatus = requestStatus;
755 | this.description = description;
756 | }
757 |
758 | @Override
759 | public int getRequestStatus() {
760 | return this.requestStatus;
761 | }
762 |
763 | @Override
764 | public String getDescription() {
765 | return "" + this.requestStatus + " " + description;
766 | }
767 | }
768 | }
769 |
770 | public static final class ResponseException extends Exception {
771 |
772 | private final Response.Status status;
773 |
774 | public ResponseException(Response.Status status, String message) {
775 | super(message);
776 | this.status = status;
777 | }
778 |
779 | public ResponseException(Response.Status status, String message, Exception e) {
780 | super(message, e);
781 | this.status = status;
782 | }
783 |
784 | public Response.Status getStatus() {
785 | return status;
786 | }
787 | }
788 |
789 | /**
790 | * Default strategy for creating and cleaning up temporary files.
791 | */
792 | private class DefaultTempFileManagerFactory implements TempFileManagerFactory {
793 | @Override
794 | public TempFileManager create() {
795 | return new DefaultTempFileManager();
796 | }
797 | }
798 |
799 | /**
800 | * Handles one session, i.e. parses the HTTP request and returns the response.
801 | */
802 | public interface IHTTPSession {
803 | void execute() throws IOException;
804 |
805 | Map getParms();
806 |
807 | Map getHeaders();
808 |
809 | /**
810 | * @return the path part of the URL.
811 | */
812 | String getUri();
813 |
814 | String getQueryParameterString();
815 |
816 | Method getMethod();
817 |
818 | InputStream getInputStream();
819 |
820 | CookieHandler getCookies();
821 |
822 | /**
823 | * Adds the files in the request body to the files map.
824 | * @arg files - map to modify
825 | */
826 | void parseBody(Map files) throws IOException, ResponseException;
827 | }
828 |
829 | protected class HTTPSession implements IHTTPSession {
830 | public static final int BUFSIZE = 8192;
831 | private final TempFileManager tempFileManager;
832 | private final OutputStream outputStream;
833 | private PushbackInputStream inputStream;
834 | private int splitbyte;
835 | private int rlen;
836 | private String uri;
837 | private Method method;
838 | private Map parms;
839 | private Map headers;
840 | private CookieHandler cookies;
841 | private String queryParameterString;
842 |
843 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) {
844 | this.tempFileManager = tempFileManager;
845 | this.inputStream = new PushbackInputStream(inputStream, BUFSIZE);
846 | this.outputStream = outputStream;
847 | }
848 |
849 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) {
850 | this.tempFileManager = tempFileManager;
851 | this.inputStream = new PushbackInputStream(inputStream, BUFSIZE);
852 | this.outputStream = outputStream;
853 | String remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString();
854 | headers = new HashMap();
855 |
856 | headers.put("remote-addr", remoteIp);
857 | headers.put("http-client-ip", remoteIp);
858 | }
859 |
860 | @Override
861 | public void execute() throws IOException {
862 | try {
863 | // Read the first 8192 bytes.
864 | // The full header should fit in here.
865 | // Apache's default header limit is 8KB.
866 | // Do NOT assume that a single read will get the entire header at once!
867 | byte[] buf = new byte[BUFSIZE];
868 | splitbyte = 0;
869 | rlen = 0;
870 | {
871 | int read = -1;
872 | try {
873 | read = inputStream.read(buf, 0, BUFSIZE);
874 | } catch (Exception e) {
875 | safeClose(inputStream);
876 | safeClose(outputStream);
877 | throw new SocketException("NanoHttpd Shutdown");
878 | }
879 | if (read == -1) {
880 | // socket was been closed
881 | safeClose(inputStream);
882 | safeClose(outputStream);
883 | throw new SocketException("NanoHttpd Shutdown");
884 | }
885 | while (read > 0) {
886 | rlen += read;
887 | splitbyte = findHeaderEnd(buf, rlen);
888 | if (splitbyte > 0)
889 | break;
890 | read = inputStream.read(buf, rlen, BUFSIZE - rlen);
891 | }
892 | }
893 |
894 | if (splitbyte < rlen) {
895 | inputStream.unread(buf, splitbyte, rlen - splitbyte);
896 | }
897 |
898 | parms = new HashMap();
899 | if(null == headers) {
900 | headers = new HashMap();
901 | }
902 |
903 | // Create a BufferedReader for parsing the header.
904 | BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, rlen)));
905 |
906 | // Decode the header into parms and header java properties
907 | Map pre = new HashMap();
908 | decodeHeader(hin, pre, parms, headers);
909 |
910 | method = Method.lookup(pre.get("method"));
911 | if (method == null) {
912 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error.");
913 | }
914 |
915 | uri = pre.get("uri");
916 |
917 | cookies = new CookieHandler(headers);
918 |
919 | // Ok, now do the serve()
920 | Response r = serve(this);
921 | if (r == null) {
922 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
923 | } else {
924 | cookies.unloadQueue(r);
925 | r.setRequestMethod(method);
926 | r.send(outputStream);
927 | }
928 | } catch (SocketException e) {
929 | // throw it out to close socket object (finalAccept)
930 | throw e;
931 | } catch (SocketTimeoutException ste) {
932 | throw ste;
933 | } catch (IOException ioe) {
934 | Response r = new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
935 | r.send(outputStream);
936 | safeClose(outputStream);
937 | } catch (ResponseException re) {
938 | Response r = new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
939 | r.send(outputStream);
940 | safeClose(outputStream);
941 | } finally {
942 | tempFileManager.clear();
943 | }
944 | }
945 |
946 | @Override
947 | public void parseBody(Map files) throws IOException, ResponseException {
948 | RandomAccessFile randomAccessFile = null;
949 | BufferedReader in = null;
950 | try {
951 |
952 | randomAccessFile = getTmpBucket();
953 |
954 | long size;
955 | if (headers.containsKey("content-length")) {
956 | size = Integer.parseInt(headers.get("content-length"));
957 | } else if (splitbyte < rlen) {
958 | size = rlen - splitbyte;
959 | } else {
960 | size = 0;
961 | }
962 |
963 | // Now read all the body and write it to f
964 | byte[] buf = new byte[512];
965 | while (rlen >= 0 && size > 0) {
966 | rlen = inputStream.read(buf, 0, (int)Math.min(size, 512));
967 | size -= rlen;
968 | if (rlen > 0) {
969 | randomAccessFile.write(buf, 0, rlen);
970 | }
971 | }
972 |
973 | // Get the raw body as a byte []
974 | ByteBuffer fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length());
975 | randomAccessFile.seek(0);
976 |
977 | // Create a BufferedReader for easily reading it as string.
978 | InputStream bin = new FileInputStream(randomAccessFile.getFD());
979 | in = new BufferedReader(new InputStreamReader(bin));
980 |
981 | // If the method is POST, there may be parameters
982 | // in data section, too, read it:
983 | if (Method.POST.equals(method)) {
984 | String contentType = "";
985 | String contentTypeHeader = headers.get("content-type");
986 |
987 | StringTokenizer st = null;
988 | if (contentTypeHeader != null) {
989 | st = new StringTokenizer(contentTypeHeader, ",; ");
990 | if (st.hasMoreTokens()) {
991 | contentType = st.nextToken();
992 | }
993 | }
994 |
995 | if ("multipart/form-data".equalsIgnoreCase(contentType)) {
996 | // Handle multipart/form-data
997 | if (!st.hasMoreTokens()) {
998 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
999 | }
1000 |
1001 | String boundaryStartString = "boundary=";
1002 | int boundaryContentStart = contentTypeHeader.indexOf(boundaryStartString) + boundaryStartString.length();
1003 | String boundary = contentTypeHeader.substring(boundaryContentStart, contentTypeHeader.length());
1004 | if (boundary.startsWith("\"") && boundary.endsWith("\"")) {
1005 | boundary = boundary.substring(1, boundary.length() - 1);
1006 | }
1007 |
1008 | decodeMultipartData(boundary, fbuf, in, parms, files);
1009 | } else {
1010 | String postLine = "";
1011 | StringBuilder postLineBuffer = new StringBuilder();
1012 | char pbuf[] = new char[512];
1013 | int read = in.read(pbuf);
1014 | while (read >= 0 && !postLine.endsWith("\r\n")) {
1015 | postLine = String.valueOf(pbuf, 0, read);
1016 | postLineBuffer.append(postLine);
1017 | read = in.read(pbuf);
1018 | }
1019 | postLine = postLineBuffer.toString().trim();
1020 | // Handle application/x-www-form-urlencoded
1021 | if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) {
1022 | decodeParms(postLine, parms);
1023 | } else if (postLine.length() != 0) {
1024 | // Special case for raw POST data => create a special files entry "postData" with raw content data
1025 | files.put("postData", postLine);
1026 | }
1027 | }
1028 | } else if (Method.PUT.equals(method)) {
1029 | files.put("content", saveTmpFile(fbuf, 0, fbuf.limit()));
1030 | }
1031 | } finally {
1032 | safeClose(randomAccessFile);
1033 | safeClose(in);
1034 | }
1035 | }
1036 |
1037 | /**
1038 | * Decodes the sent headers and loads the data into Key/value pairs
1039 | */
1040 | private void decodeHeader(BufferedReader in, Map pre, Map parms, Map headers)
1041 | throws ResponseException {
1042 | try {
1043 | // Read the request line
1044 | String inLine = in.readLine();
1045 | if (inLine == null) {
1046 | return;
1047 | }
1048 |
1049 | StringTokenizer st = new StringTokenizer(inLine);
1050 | if (!st.hasMoreTokens()) {
1051 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html");
1052 | }
1053 |
1054 | pre.put("method", st.nextToken());
1055 |
1056 | if (!st.hasMoreTokens()) {
1057 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html");
1058 | }
1059 |
1060 | String uri = st.nextToken();
1061 |
1062 | // Decode parameters from the URI
1063 | int qmi = uri.indexOf('?');
1064 | if (qmi >= 0) {
1065 | decodeParms(uri.substring(qmi + 1), parms);
1066 | uri = decodePercent(uri.substring(0, qmi));
1067 | } else {
1068 | uri = decodePercent(uri);
1069 | }
1070 |
1071 | // If there's another token, it's protocol version,
1072 | // followed by HTTP headers. Ignore version but parse headers.
1073 | // NOTE: this now forces header names lowercase since they are
1074 | // case insensitive and vary by client.
1075 | if (st.hasMoreTokens()) {
1076 | String line = in.readLine();
1077 | while (line != null && line.trim().length() > 0) {
1078 | int p = line.indexOf(':');
1079 | if (p >= 0)
1080 | headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim());
1081 | line = in.readLine();
1082 | }
1083 | }
1084 |
1085 | pre.put("uri", uri);
1086 | } catch (IOException ioe) {
1087 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
1088 | }
1089 | }
1090 |
1091 | /**
1092 | * Decodes the Multipart Body data and put it into Key/Value pairs.
1093 | */
1094 | private void decodeMultipartData(String boundary, ByteBuffer fbuf, BufferedReader in, Map parms,
1095 | Map files) throws ResponseException {
1096 | try {
1097 | int[] bpositions = getBoundaryPositions(fbuf, boundary.getBytes());
1098 | int boundarycount = 1;
1099 | String mpline = in.readLine();
1100 | while (mpline != null) {
1101 | if (!mpline.contains(boundary)) {
1102 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but next chunk does not start with boundary. Usage: GET /example/file.html");
1103 | }
1104 | boundarycount++;
1105 | Map item = new HashMap();
1106 | mpline = in.readLine();
1107 | while (mpline != null && mpline.trim().length() > 0) {
1108 | int p = mpline.indexOf(':');
1109 | if (p != -1) {
1110 | item.put(mpline.substring(0, p).trim().toLowerCase(Locale.US), mpline.substring(p + 1).trim());
1111 | }
1112 | mpline = in.readLine();
1113 | }
1114 | if (mpline != null) {
1115 | String contentDisposition = item.get("content-disposition");
1116 | if (contentDisposition == null) {
1117 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but no content-disposition info found. Usage: GET /example/file.html");
1118 | }
1119 | StringTokenizer st = new StringTokenizer(contentDisposition, ";");
1120 | Map disposition = new HashMap();
1121 | while (st.hasMoreTokens()) {
1122 | String token = st.nextToken().trim();
1123 | int p = token.indexOf('=');
1124 | if (p != -1) {
1125 | disposition.put(token.substring(0, p).trim().toLowerCase(Locale.US), token.substring(p + 1).trim());
1126 | }
1127 | }
1128 | String pname = disposition.get("name");
1129 | pname = pname.substring(1, pname.length() - 1);
1130 |
1131 | String value = "";
1132 | if (item.get("content-type") == null) {
1133 | while (mpline != null && !mpline.contains(boundary)) {
1134 | mpline = in.readLine();
1135 | if (mpline != null) {
1136 | int d = mpline.indexOf(boundary);
1137 | if (d == -1) {
1138 | value += mpline;
1139 | } else {
1140 | value += mpline.substring(0, d - 2);
1141 | }
1142 | }
1143 | }
1144 | } else {
1145 | if (boundarycount > bpositions.length) {
1146 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "Error processing request");
1147 | }
1148 | int offset = stripMultipartHeaders(fbuf, bpositions[boundarycount - 2]);
1149 | String path = saveTmpFile(fbuf, offset, bpositions[boundarycount - 1] - offset - 4);
1150 | files.put(pname, path);
1151 | value = disposition.get("filename");
1152 | value = value.substring(1, value.length() - 1);
1153 | do {
1154 | mpline = in.readLine();
1155 | } while (mpline != null && !mpline.contains(boundary));
1156 | }
1157 | parms.put(pname, value);
1158 | }
1159 | }
1160 | } catch (IOException ioe) {
1161 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
1162 | }
1163 | }
1164 |
1165 | /**
1166 | * Find byte index separating header from body. It must be the last byte of the first two sequential new lines.
1167 | */
1168 | private int findHeaderEnd(final byte[] buf, int rlen) {
1169 | int splitbyte = 0;
1170 | while (splitbyte + 3 < rlen) {
1171 | if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') {
1172 | return splitbyte + 4;
1173 | }
1174 | splitbyte++;
1175 | }
1176 | return 0;
1177 | }
1178 |
1179 | /**
1180 | * Find the byte positions where multipart boundaries start.
1181 | */
1182 | private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) {
1183 | int matchcount = 0;
1184 | int matchbyte = -1;
1185 | List matchbytes = new ArrayList();
1186 | for (int i = 0; i < b.limit(); i++) {
1187 | if (b.get(i) == boundary[matchcount]) {
1188 | if (matchcount == 0)
1189 | matchbyte = i;
1190 | matchcount++;
1191 | if (matchcount == boundary.length) {
1192 | matchbytes.add(matchbyte);
1193 | matchcount = 0;
1194 | matchbyte = -1;
1195 | }
1196 | } else {
1197 | i -= matchcount;
1198 | matchcount = 0;
1199 | matchbyte = -1;
1200 | }
1201 | }
1202 | int[] ret = new int[matchbytes.size()];
1203 | for (int i = 0; i < ret.length; i++) {
1204 | ret[i] = matchbytes.get(i);
1205 | }
1206 | return ret;
1207 | }
1208 |
1209 | /**
1210 | * Retrieves the content of a sent file and saves it to a temporary file. The full path to the saved file is returned.
1211 | */
1212 | private String saveTmpFile(ByteBuffer b, int offset, int len) {
1213 | String path = "";
1214 | if (len > 0) {
1215 | FileOutputStream fileOutputStream = null;
1216 | try {
1217 | TempFile tempFile = tempFileManager.createTempFile();
1218 | ByteBuffer src = b.duplicate();
1219 | fileOutputStream = new FileOutputStream(tempFile.getName());
1220 | FileChannel dest = fileOutputStream.getChannel();
1221 | src.position(offset).limit(offset + len);
1222 | dest.write(src.slice());
1223 | path = tempFile.getName();
1224 | } catch (Exception e) { // Catch exception if any
1225 | throw new Error(e); // we won't recover, so throw an error
1226 | } finally {
1227 | safeClose(fileOutputStream);
1228 | }
1229 | }
1230 | return path;
1231 | }
1232 |
1233 | private RandomAccessFile getTmpBucket() {
1234 | try {
1235 | TempFile tempFile = tempFileManager.createTempFile();
1236 | return new RandomAccessFile(tempFile.getName(), "rw");
1237 | } catch (Exception e) {
1238 | throw new Error(e); // we won't recover, so throw an error
1239 | }
1240 | }
1241 |
1242 | /**
1243 | * It returns the offset separating multipart file headers from the file's data.
1244 | */
1245 | private int stripMultipartHeaders(ByteBuffer b, int offset) {
1246 | int i;
1247 | for (i = offset; i < b.limit(); i++) {
1248 | if (b.get(i) == '\r' && b.get(++i) == '\n' && b.get(++i) == '\r' && b.get(++i) == '\n') {
1249 | break;
1250 | }
1251 | }
1252 | return i + 1;
1253 | }
1254 |
1255 | /**
1256 | * Decodes parameters in percent-encoded URI-format ( e.g. "name=Jack%20Daniels&pass=Single%20Malt" ) and
1257 | * adds them to given Map. NOTE: this doesn't support multiple identical keys due to the simplicity of Map.
1258 | */
1259 | private void decodeParms(String parms, Map p) {
1260 | if (parms == null) {
1261 | queryParameterString = "";
1262 | return;
1263 | }
1264 |
1265 | queryParameterString = parms;
1266 | StringTokenizer st = new StringTokenizer(parms, "&");
1267 | while (st.hasMoreTokens()) {
1268 | String e = st.nextToken();
1269 | int sep = e.indexOf('=');
1270 | if (sep >= 0) {
1271 | p.put(decodePercent(e.substring(0, sep)).trim(),
1272 | decodePercent(e.substring(sep + 1)));
1273 | } else {
1274 | p.put(decodePercent(e).trim(), "");
1275 | }
1276 | }
1277 | }
1278 |
1279 | @Override
1280 | public final Map getParms() {
1281 | return parms;
1282 | }
1283 |
1284 | public String getQueryParameterString() {
1285 | return queryParameterString;
1286 | }
1287 |
1288 | @Override
1289 | public final Map getHeaders() {
1290 | return headers;
1291 | }
1292 |
1293 | @Override
1294 | public final String getUri() {
1295 | return uri;
1296 | }
1297 |
1298 | @Override
1299 | public final Method getMethod() {
1300 | return method;
1301 | }
1302 |
1303 | @Override
1304 | public final InputStream getInputStream() {
1305 | return inputStream;
1306 | }
1307 |
1308 | @Override
1309 | public CookieHandler getCookies() {
1310 | return cookies;
1311 | }
1312 | }
1313 |
1314 | public static class Cookie {
1315 | private String n, v, e;
1316 |
1317 | public Cookie(String name, String value, String expires) {
1318 | n = name;
1319 | v = value;
1320 | e = expires;
1321 | }
1322 |
1323 | public Cookie(String name, String value) {
1324 | this(name, value, 30);
1325 | }
1326 |
1327 | public Cookie(String name, String value, int numDays) {
1328 | n = name;
1329 | v = value;
1330 | e = getHTTPTime(numDays);
1331 | }
1332 |
1333 | public String getHTTPHeader() {
1334 | String fmt = "%s=%s; expires=%s";
1335 | return String.format(fmt, n, v, e);
1336 | }
1337 |
1338 | public static String getHTTPTime(int days) {
1339 | Calendar calendar = Calendar.getInstance();
1340 | SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
1341 | dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
1342 | calendar.add(Calendar.DAY_OF_MONTH, days);
1343 | return dateFormat.format(calendar.getTime());
1344 | }
1345 | }
1346 |
1347 | /**
1348 | * Provides rudimentary support for cookies.
1349 | * Doesn't support 'path', 'secure' nor 'httpOnly'.
1350 | * Feel free to improve it and/or add unsupported features.
1351 | *
1352 | * @author LordFokas
1353 | */
1354 | public class CookieHandler implements Iterable {
1355 | private HashMap cookies = new HashMap();
1356 | private ArrayList queue = new ArrayList();
1357 |
1358 | public CookieHandler(Map httpHeaders) {
1359 | String raw = httpHeaders.get("cookie");
1360 | if (raw != null) {
1361 | String[] tokens = raw.split(";");
1362 | for (String token : tokens) {
1363 | String[] data = token.trim().split("=");
1364 | if (data.length == 2) {
1365 | cookies.put(data[0], data[1]);
1366 | }
1367 | }
1368 | }
1369 | }
1370 |
1371 | @Override public Iterator iterator() {
1372 | return cookies.keySet().iterator();
1373 | }
1374 |
1375 | /**
1376 | * Read a cookie from the HTTP Headers.
1377 | *
1378 | * @param name The cookie's name.
1379 | * @return The cookie's value if it exists, null otherwise.
1380 | */
1381 | public String read(String name) {
1382 | return cookies.get(name);
1383 | }
1384 |
1385 | /**
1386 | * Sets a cookie.
1387 | *
1388 | * @param name The cookie's name.
1389 | * @param value The cookie's value.
1390 | * @param expires How many days until the cookie expires.
1391 | */
1392 | public void set(String name, String value, int expires) {
1393 | queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires)));
1394 | }
1395 |
1396 | public void set(Cookie cookie) {
1397 | queue.add(cookie);
1398 | }
1399 |
1400 | /**
1401 | * Set a cookie with an expiration date from a month ago, effectively deleting it on the client side.
1402 | *
1403 | * @param name The cookie name.
1404 | */
1405 | public void delete(String name) {
1406 | set(name, "-delete-", -30);
1407 | }
1408 |
1409 | /**
1410 | * Internally used by the webserver to add all queued cookies into the Response's HTTP Headers.
1411 | *
1412 | * @param response The Response object to which headers the queued cookies will be added.
1413 | */
1414 | public void unloadQueue(Response response) {
1415 | for (Cookie cookie : queue) {
1416 | response.addHeader("Set-Cookie", cookie.getHTTPHeader());
1417 | }
1418 | }
1419 | }
1420 | }
1421 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/SettingsActivity.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver;
2 |
3 | import android.app.*;
4 | import android.util.Log;
5 | import android.os.*;
6 | import android.view.*;
7 | import android.widget.*;
8 | import android.content.*;
9 | import android.preference.*;
10 |
11 | /** This class defines the Android settings page, such as it is. */
12 | public class SettingsActivity extends PreferenceActivity
13 | {
14 | @Override
15 | public void onCreate(Bundle savedInstanceState)
16 | {
17 | super.onCreate (savedInstanceState);
18 | addPreferencesFromResource (R.xml.preferences);
19 | }
20 |
21 | }
22 |
23 |
24 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/Version.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmediaserver;
8 | import java.text.*;
9 | import java.util.*;
10 | import java.util.zip.*;
11 | import android.content.*;
12 | import android.content.pm.*;
13 |
14 | public class Version
15 | {
16 | private static final String versionString = "0.0.5";
17 |
18 | public static String getVersionString () { return versionString; }
19 |
20 | public static String getBuildDateString (Context context)
21 | {
22 | long date = getBuildDate (context);
23 | return new SimpleDateFormat ("yyyy/MM/dd hh:mm", Locale.getDefault())
24 | .format(date);
25 | }
26 |
27 | public static long getBuildDate (Context context)
28 | {
29 | try
30 | {
31 | ApplicationInfo ai = context.getPackageManager()
32 | .getApplicationInfo(context.getPackageName(), 0);
33 | ZipFile zf = new ZipFile(ai.sourceDir);
34 | ZipEntry ze = zf.getEntry("classes.dex");
35 | long time = ze.getTime();
36 | zf.close();
37 | return time;
38 | }
39 | catch (Exception e)
40 | {
41 | return 0;
42 | }
43 | }
44 | }
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/WebPlayerService.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 | package net.kevinboone.androidmediaserver;
7 |
8 | import android.app.Service;
9 | import android.os.*;
10 | import android.view.*;
11 | import android.widget.*;
12 | import android.media.*;
13 | import android.content.*;
14 | import java.io.*;
15 |
16 | public class WebPlayerService extends Service
17 | {
18 | private WebServer server = null;
19 |
20 | @Override
21 | public void onCreate()
22 | {
23 | try
24 | {
25 | server = new WebServer (this);
26 | server.start();
27 | }
28 | catch (Throwable e)
29 | {
30 | // _Any_ exception thrown out of here will stop the app from running
31 | // at all, so the user will never know what went wrong
32 | e.printStackTrace();
33 | //throw new RuntimeException (e);
34 | }
35 | }
36 |
37 | @Override
38 | public int onStartCommand(Intent intent, int flags, int startId)
39 | {
40 | return START_STICKY;
41 | }
42 |
43 | @Override
44 | public IBinder onBind (Intent intent)
45 | {
46 | return null;
47 | }
48 |
49 | @Override
50 | public void onDestroy()
51 | {
52 | if (server != null)
53 | server.stop();
54 | }
55 |
56 | }
57 |
58 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/client/Client.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver.client;
2 |
3 | import java.io.*;
4 | import java.net.*;
5 | import org.json.*;
6 |
7 | public class Client
8 | {
9 | protected int port;
10 | protected String host;
11 |
12 | public Client (String host, int port)
13 | {
14 | this.host = host;
15 | this.port = port;
16 | }
17 |
18 | private String streamToString (java.io.InputStream is)
19 | {
20 | java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A");
21 | return s.hasNext() ? s.next() : "";
22 | }
23 |
24 | public JSONObject runCommand (String command)
25 | throws ClientException, IOException
26 | {
27 | try
28 | {
29 | URL url = new URL ("http://" + host + ":" + port + "/cmd?cmd=" +
30 | URLEncoder.encode (command));
31 | InputStream is = url.openStream ();
32 | String result = streamToString (is);
33 | //System.out.println ("result= "+ result);
34 | JSONObject jo = new JSONObject (result);
35 | is.close();
36 | return jo;
37 | }
38 | catch (MalformedURLException e)
39 | {
40 | // This should never happen, unless the JVM is broken
41 | throw new ClientException (e.toString());
42 | }
43 | catch (JSONException e)
44 | {
45 | throw new ClientException (e.toString());
46 | }
47 | }
48 |
49 |
50 | protected void checkJSONResponse (JSONObject response)
51 | throws ClientException
52 | {
53 | try
54 | {
55 | int status = response.getInt ("status");
56 | if (status != 0)
57 | {
58 | String msg = response.getString ("message");
59 | if (msg == null)
60 | throw new ClientException ("Server returned error code " + status);
61 | else
62 | throw new ClientException (msg);
63 | }
64 | }
65 | catch (JSONException e)
66 | {
67 | throw new ClientException ("Error parsing JSON: " + e.toString());
68 | }
69 | }
70 |
71 |
72 | public void play () throws ClientException, IOException
73 | {
74 | JSONObject response = runCommand ("play");
75 | checkJSONResponse (response);
76 | }
77 |
78 |
79 | public void pause () throws ClientException, IOException
80 | {
81 | JSONObject response = runCommand ("pause");
82 | checkJSONResponse (response);
83 | }
84 |
85 |
86 | public void stop () throws ClientException, IOException
87 | {
88 | JSONObject response = runCommand ("stop");
89 | checkJSONResponse (response);
90 | }
91 |
92 |
93 | public void next () throws ClientException, IOException
94 | {
95 | JSONObject response = runCommand ("next");
96 | checkJSONResponse (response);
97 | }
98 |
99 | public void prev () throws ClientException, IOException
100 | {
101 | JSONObject response = runCommand ("prev");
102 | checkJSONResponse (response);
103 | }
104 |
105 |
106 | public Status getStatus () throws ClientException, IOException
107 | {
108 | try
109 | {
110 | JSONObject response = runCommand ("status");
111 | checkJSONResponse (response);
112 | Status status = new Status();
113 | String sTransportStatus = response.getString ("transport_status");
114 | if ("playing".equals (sTransportStatus))
115 | status.setTransportStatus (Status.TransportStatus.PLAYING);
116 | else if ("stopped".equals (sTransportStatus))
117 | status.setTransportStatus (Status.TransportStatus.STOPPED);
118 | else if ("paused".equals (sTransportStatus))
119 | status.setTransportStatus (Status.TransportStatus.PAUSED);
120 | else
121 | status.setTransportStatus (Status.TransportStatus.UNKNOWN);
122 |
123 | status.setTitle (response.getString ("title"));
124 | status.setUri (response.getString ("uri"));
125 | status.setArtist (response.getString ("artist"));
126 | status.setAlbum (response.getString ("album"));
127 | status.setPosition (Integer.parseInt (response.getString
128 | ("transport_position")));
129 | status.setDuration (Integer.parseInt (response.getString
130 | ("transport_duration")));
131 |
132 | return status;
133 | }
134 | catch (JSONException e)
135 | {
136 | throw new ClientException ("Error parsing response from server: " +
137 | e.toString());
138 | }
139 | }
140 |
141 | public static void main (String[] args) throws Exception
142 | {
143 | Client client = new Client ("192.168.1.104", 30000); // TEST -- change
144 | if (args.length == 0)
145 | {
146 | Status ts = client.getStatus ();
147 | System.out.println (ts);
148 | }
149 | else
150 | {
151 | if ("play".equals (args[0]))
152 | client.play();
153 | else if ("pause".equals (args[0]))
154 | client.pause();
155 | else if ("stop".equals (args[0]))
156 | client.stop();
157 | else if ("next".equals (args[0]))
158 | client.next();
159 | else if ("prev".equals (args[0]))
160 | client.prev();
161 | }
162 | }
163 |
164 | }
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/client/ClientException.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver.client;
2 |
3 | import java.io.*;
4 | import java.net.*;
5 |
6 | public class ClientException extends Exception
7 | {
8 | public ClientException (String msg)
9 | {
10 | super (msg);
11 | }
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/client/Status.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver.client;
2 |
3 | import java.io.*;
4 | import java.net.*;
5 | import org.json.*;
6 |
7 | public class Status
8 | {
9 | protected TransportStatus transportStatus = TransportStatus.UNKNOWN;
10 | protected int position;
11 | protected int duration;
12 | protected String title;
13 | protected String artist;
14 | protected String album;
15 | protected String uri;
16 |
17 | public enum TransportStatus {UNKNOWN, STOPPED, PAUSED, PLAYING};
18 |
19 | public void setTransportStatus (TransportStatus ts)
20 | {
21 | this.transportStatus = ts;
22 | }
23 |
24 | public TransportStatus getTransportStatus ()
25 | {
26 | return transportStatus;
27 | }
28 |
29 | public void setTitle (String s) { title = s; }
30 | public void setUri (String s) { uri = s; }
31 | public void setArtist (String s) { artist = s; }
32 | public void setAlbum (String s) { album = s; }
33 | public void setDuration (int d) { duration = d; }
34 | public void setPosition (int d) { position = d; }
35 |
36 | public String getTitle () { return title; }
37 | public String getUri () { return uri; }
38 | public String getArtist () { return artist; }
39 | public String getAlbum () { return album; }
40 | public int getPosition () { return position; }
41 | public int getDuration () { return duration; }
42 |
43 |
44 | public static String transportStatusToString (TransportStatus ts)
45 | {
46 | switch (ts)
47 | {
48 | case STOPPED: return "stopped";
49 | case PAUSED: return "paused";
50 | case PLAYING: return "playing";
51 | }
52 | return "unknown";
53 | }
54 |
55 | public String toString()
56 | {
57 | StringBuffer sb = new StringBuffer();
58 | sb.append ("Transport status: ");
59 | sb.append (transportStatusToString (transportStatus));
60 | sb.append ("\n");
61 | sb.append ("Title: ");
62 | sb.append (title);
63 | sb.append ("\n");
64 | sb.append ("Album: ");
65 | sb.append (album);
66 | sb.append ("\n");
67 | sb.append ("Artist: ");
68 | sb.append (artist);
69 | sb.append ("\n");
70 | sb.append ("Position: ");
71 | sb.append ("" + position);
72 | sb.append ("\n");
73 | sb.append ("Duration: ");
74 | sb.append ("" + duration);
75 | sb.append ("\n");
76 | return new String (sb);
77 | }
78 |
79 |
80 | }
81 |
82 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/AlreadyAtEndOfPlaylistException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class AlreadyAtEndOfPlaylistException extends PlayerException
10 | {
11 | public AlreadyAtEndOfPlaylistException ()
12 | {
13 | super (Errors.ERR_ALREADY_AT_END_OF_PLAYLIST);
14 | }
15 |
16 | }
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/AlreadyAtStartOfPlaylistException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class AlreadyAtStartOfPlaylistException extends PlayerException
10 | {
11 | public AlreadyAtStartOfPlaylistException ()
12 | {
13 | super (Errors.ERR_ALREADY_AT_START_OF_PLAYLIST);
14 | }
15 |
16 | }
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/AndroidEqUtil.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 |
10 | public class AndroidEqUtil
11 | {
12 | /** Formats the frequency range as bizarrely reported by the EQ
13 | * API into something readable. */
14 | public static String formatBandLabel (int[] band)
15 | {
16 | return milliHzToString(band[0]) + "-" + milliHzToString(band[1]);
17 | }
18 |
19 |
20 | private static String milliHzToString (int milliHz)
21 | {
22 | if (milliHz < 1000) return "";
23 | if (milliHz < 1000000)
24 | return "" + (milliHz / 1000) + "Hz";
25 | else
26 | return "" + (milliHz / 1000000) + "kHz";
27 | }
28 | }
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/AudioDatabase.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015-2021
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 | import java.util.*;
9 | import java.io.*;
10 | import java.net.*;
11 | import android.content.*;
12 | import android.media.MediaPlayer;
13 | import android.util.Log;
14 | import android.media.*;
15 | import android.content.*;
16 | import android.database.*;
17 | import android.net.Uri;
18 | import android.provider.MediaStore;
19 | import net.kevinboone.textutils.*;
20 |
21 | /** This class integrates the music server with the Android built-in
22 | media scanner. */
23 | public class AudioDatabase
24 | {
25 | private final String GENRE_ID = MediaStore.Audio.Genres._ID;
26 | private final String GENRE_NAME = MediaStore.Audio.Genres.NAME;
27 | private final String AUDIO_ID = MediaStore.Audio.Media._ID;
28 |
29 | protected TreeSet albums = new TreeSet();
30 | protected TreeSet artists = new TreeSet();
31 | protected TreeSet composers = new TreeSet();
32 | protected TreeSet genres = new TreeSet();
33 | MediaMetadataRetriever mmr = new MediaMetadataRetriever();
34 |
35 | protected int approxNumTracks = 0;
36 |
37 | /**
38 | Scan for albums, etc., in the Android media database. Note that this
39 | method does not cause Android to rescan its filesystem.
40 | */
41 | public void scan(Context context)
42 | {
43 | Log.w ("AMS", "Starting media database scan");
44 | albums = new TreeSet(); // Clear any old entries
45 | artists = new TreeSet(); // Clear any old entries
46 | composers = new TreeSet(); // Clear any old entries
47 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
48 | approxNumTracks = 0;
49 | Cursor cur = context.getContentResolver().query(uri, null,
50 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
51 | if (cur.moveToFirst())
52 | {
53 | int artistColumn = cur.getColumnIndex(MediaStore.Audio.Media.ARTIST);
54 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
55 | int composerColumn = cur.getColumnIndex(MediaStore.Audio.Media.COMPOSER);
56 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
57 |
58 | do
59 | {
60 | long id = cur.getLong (idColumn);
61 | Uri extUri = ContentUris.withAppendedId
62 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
63 | String album = cur.getString (albumColumn);
64 | if (album != null && album.length() > 0)
65 | albums.add (album);
66 | String composer = cur.getString (composerColumn);
67 | if (composer != null && composer.length() > 0)
68 | composers.add (composer);
69 | String artist = cur.getString (artistColumn);
70 | if (artist != null && artist.length() > 0)
71 | artists.add (artist);
72 | approxNumTracks++;
73 | } while (cur.moveToNext());
74 | cur.close();
75 |
76 | Log.d ("AMS", "Enumerating genres");
77 | cur = context.getContentResolver().query (
78 | MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
79 | new String[] { MediaStore.Audio.Genres._ID,
80 | MediaStore.Audio.Genres.NAME}, null, null, null);
81 | for (cur.moveToFirst(); !cur.isAfterLast(); cur.moveToNext())
82 | {
83 | String genreID = cur.getString(0);
84 | Log.d ("AMS", "genre ID is " + genreID);
85 | if (genreID != null)
86 | {
87 | String genreName = cur.getString(1);
88 | if (genreHasTracks(context, genreID))
89 | genres.add (genreName);
90 | }
91 | }
92 | cur.close();
93 | }
94 | else
95 | Log.w ("AMS", "Media database scan produced no results");
96 | Log.w ("AMS", "Done media database scan");
97 | }
98 |
99 |
100 | /**
101 | Try to determine whether the specified genre ID is associated with
102 | any tracks. This is to prevent including empty genres in the list.
103 | Notethat this method takes a genre ID, not a genre name, and so is
104 | probably not much use except as a helper to the scan() method
105 | */
106 | public boolean genreHasTracks (Context context, String genreID)
107 | {
108 | Uri uri = MediaStore.Audio.Genres.Members.getContentUri
109 | ("external", Long.valueOf(genreID));
110 |
111 | String[] projection = new String[]{MediaStore.Audio.Media.TITLE,
112 | MediaStore.Audio.Media._ID};
113 |
114 | Cursor cur = context.getContentResolver().query(uri, projection,
115 | null, null, null);
116 |
117 | boolean ret;
118 |
119 | if (cur.moveToFirst())
120 | ret = true;
121 | else
122 | ret = false;
123 |
124 | cur.close();
125 |
126 | return ret;
127 | }
128 |
129 |
130 | public Set getAlbumsByArtist (Context context, String artist)
131 | {
132 | Set results = new TreeSet();
133 |
134 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
135 | Cursor cur = context.getContentResolver().query(uri, null,
136 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
137 | if (cur.moveToFirst())
138 | {
139 | int artistColumn = cur.getColumnIndex(MediaStore.Audio.Media.ARTIST);
140 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
141 |
142 | do
143 | {
144 | String candArtist = cur.getString (artistColumn);
145 | if (candArtist != null && candArtist.length() > 0
146 | && candArtist.equalsIgnoreCase (artist))
147 | {
148 | String album = cur.getString (albumColumn);
149 | if (album != null && album.length() > 0)
150 | results.add (album);
151 | }
152 | } while (cur.moveToNext());
153 | }
154 | cur.close();
155 |
156 | return results;
157 | }
158 |
159 |
160 | public Set getAlbumsByComposer (Context context, String composer)
161 | {
162 | Set results = new TreeSet();
163 |
164 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
165 | Cursor cur = context.getContentResolver().query(uri, null,
166 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
167 | if (cur.moveToFirst())
168 | {
169 | int composerColumn = cur.getColumnIndex(MediaStore.Audio.Media.COMPOSER);
170 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
171 |
172 | do
173 | {
174 | String candComposer = cur.getString (composerColumn);
175 | if (candComposer != null && candComposer.length() > 0
176 | && candComposer.equalsIgnoreCase (composer))
177 | {
178 | String album = cur.getString (albumColumn);
179 | if (album != null && album.length() > 0)
180 | results.add (album);
181 | }
182 | } while (cur.moveToNext());
183 | }
184 | cur.close();
185 |
186 | return results;
187 | }
188 |
189 |
190 | public Set getAlbumsByGenre (Context context, String genre)
191 | {
192 | Set results = new TreeSet();
193 | for (String album : albums)
194 | {
195 | List trackUris = getAlbumURIs (context, album);
196 | for (String trackUri : trackUris)
197 | {
198 | TrackInfo ti = getTrackInfo (context, trackUri, true);
199 | if (genre.equals (ti.genre))
200 | {
201 | results.add (album);
202 | break;
203 | }
204 | // Because this is so slow, only check the first track of each
205 | // album for genre
206 | break;
207 | }
208 | }
209 |
210 | return results;
211 | }
212 |
213 | public Set getAlbums()
214 | {
215 | return albums;
216 | }
217 |
218 | public Set getArtists()
219 | {
220 | return artists;
221 | }
222 |
223 | public Set getComposers()
224 | {
225 | return composers;
226 | }
227 |
228 | public Set getGenres()
229 | {
230 | return genres;
231 | }
232 |
233 |
234 | /** Get a list of content URIs (of the form content:...) for
235 | the specified album */
236 | public List getAlbumURIs (Context context, String album)
237 | {
238 | Vector list = new Vector();
239 |
240 | String escAlbum = EscapeUtils.escapeSQL (album);
241 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
242 | Cursor cur = context.getContentResolver().query(uri, null,
243 | MediaStore.Audio.Media.IS_MUSIC + " = 1 and "
244 | + MediaStore.Audio.Media.ALBUM + "= '" + escAlbum + "'" , null,
245 | MediaStore.Audio.Media.TRACK + "," + MediaStore.Audio.Media.TITLE);
246 | if (cur.moveToFirst())
247 | {
248 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
249 |
250 | do
251 | {
252 | Long id = cur.getLong (idColumn);
253 | Uri extUri = ContentUris.withAppendedId
254 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
255 | list.add (extUri.toString());
256 | } while (cur.moveToNext());
257 | cur.close();
258 | }
259 | return list;
260 | }
261 |
262 | /** Try to get track info from the uri, which may be a simple filename,
263 | or a content: uri. If it fails, return a TrackInfo with only the
264 | URI and title (which is made up) set. THis method does _not_
265 | retrieve genre information, which is very slow. */
266 | TrackInfo getTrackInfo (Context context, String uri)
267 | {
268 | return getTrackInfo (context, uri, false);
269 | }
270 |
271 | /** Try to get track info from the uri, which may be a simple filename,
272 | or a content: uri. If it fails, return a TrackInfo with only the
273 | URI and title (which is made up) set */
274 | TrackInfo getTrackInfo (Context context, String uri, boolean includeGenre)
275 | {
276 | try
277 | {
278 | if (uri.startsWith ("content:"))
279 | {
280 | android.net.Uri contentUri = android.net.Uri.parse (uri);
281 | mmr.setDataSource (context, contentUri);
282 | }
283 | else
284 | {
285 | String filename = uri;
286 | mmr.setDataSource (filename);
287 | }
288 |
289 | TrackInfo ti = new TrackInfo(uri);
290 | ti.title = mmr.extractMetadata (mmr.METADATA_KEY_TITLE);
291 | ti.artist = mmr.extractMetadata (mmr.METADATA_KEY_ARTIST);
292 | ti.composer = mmr.extractMetadata (mmr.METADATA_KEY_COMPOSER);
293 | ti.album = mmr.extractMetadata (mmr.METADATA_KEY_ALBUM);
294 | ti.trackNumber = mmr.extractMetadata (mmr.METADATA_KEY_CD_TRACK_NUMBER);
295 | if (includeGenre)
296 | ti.genre = mmr.extractMetadata (mmr.METADATA_KEY_GENRE);
297 | else
298 | ti.genre = "";
299 | if (ti.trackNumber == null || ti.trackNumber == "")
300 | ti.trackNumber = "1";
301 | if (ti.title == null) ti.title = "?";
302 | if (ti.artist == null) ti.artist = "?";
303 | if (ti.composer == null) ti.composer = "?";
304 | if (ti.album == null) ti.album = "?";
305 | return ti;
306 | }
307 | catch (Throwable e)
308 | {
309 | Log.w ("AMS", "Error fetching media metadata: " + e.toString());
310 | TrackInfo ti = new TrackInfo(uri);
311 | ti.title = TrackInfo.makeTitleFromUri (uri);
312 | return ti;
313 | }
314 | }
315 |
316 | /**
317 | Try to get the embedded picture for an item, if there is one.
318 | If not, or in the event of error, return null. For some reason,
319 | calls on the metadata extractor are not thread safe, so this method
320 | has to be synchronized :/
321 | */
322 | synchronized byte[] getEmbeddedPicture (Context context, String uri)
323 | {
324 | try
325 | {
326 | if (uri.startsWith ("content:"))
327 | {
328 | android.net.Uri contentUri = android.net.Uri.parse (uri);
329 | mmr.setDataSource (context, contentUri);
330 | }
331 | else
332 | {
333 | String filename = uri;
334 | mmr.setDataSource (filename);
335 | }
336 |
337 | byte[] ep = mmr.getEmbeddedPicture ();
338 | return ep;
339 | }
340 | catch (Throwable e)
341 | {
342 | Log.w ("AMS", "Error fetching embdedded picture: " + e.toString());
343 | return null;
344 | }
345 | }
346 |
347 |
348 | public String getFilePathFromContentUri (Context context, Uri uri)
349 | {
350 | String filePath;
351 | String[] filePathColumn = {android.provider.MediaStore.MediaColumns.DATA};
352 |
353 | Cursor cursor = context.getContentResolver().query (uri, filePathColumn,
354 | null, null, null);
355 | cursor.moveToFirst();
356 |
357 | int columnIndex = cursor.getColumnIndex (filePathColumn[0]);
358 | filePath = cursor.getString(columnIndex);
359 | cursor.close();
360 | return filePath;
361 | }
362 |
363 |
364 | public Set findTracks (Context context, SearchSpec search,
365 | int start, int num)
366 | {
367 | Set results = new TreeSet();
368 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
369 | Cursor cur = context.getContentResolver().query(uri, null,
370 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null,
371 | MediaStore.Audio.Media.TITLE + "," + MediaStore.Audio.Media.TRACK);
372 | int titleColumn = cur.getColumnIndex(MediaStore.Audio.Media.TITLE);
373 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
374 | int count = 0;
375 |
376 | if (cur.moveToFirst())
377 | {
378 | do
379 | {
380 | long id = cur.getLong (idColumn);
381 | Uri extUri = ContentUris.withAppendedId
382 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
383 | String title = cur.getString (titleColumn);
384 | if (search == null)
385 | {
386 | if (count >= start)
387 | {
388 | results.add (extUri.toString());
389 | count++;
390 | }
391 | }
392 | else
393 | {
394 | if (title != null && title.length() > 0)
395 | {
396 | if (title.toLowerCase(Locale.getDefault()).indexOf
397 | (search.getText().toLowerCase(Locale.getDefault())) >= 0)
398 | {
399 | if (count >= start)
400 | results.add (extUri.toString());
401 | }
402 | }
403 | }
404 | count++;
405 | } while (cur.moveToNext() && (num < 0 || results.size() <= num));
406 |
407 | cur.close();
408 | }
409 | return results;
410 | }
411 |
412 |
413 | public int getApproxNumTracks ()
414 | {
415 | return approxNumTracks;
416 | }
417 |
418 |
419 | public Set getMatchingAlbums (SearchSpec ss, int max)
420 | {
421 | Set results = new TreeSet();
422 |
423 | String text = ss.getText().toLowerCase(Locale.getDefault());
424 | for (String album : albums)
425 | {
426 | if (album.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
427 | {
428 | results.add (album);
429 | }
430 | if (results.size() >= max) break;
431 | }
432 |
433 | return results;
434 | }
435 |
436 |
437 | public Set getMatchingArtists (SearchSpec ss, int max)
438 | {
439 | Set results = new TreeSet();
440 |
441 | String text = ss.getText().toLowerCase(Locale.getDefault());
442 | for (String artist : artists)
443 | {
444 | if (artist.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
445 | {
446 | results.add (artist);
447 | }
448 | if (results.size() >= max) break;
449 | }
450 |
451 | return results;
452 | }
453 |
454 |
455 | public Set getMatchingComposers (SearchSpec ss, int max)
456 | {
457 | Set results = new TreeSet();
458 |
459 | String text = ss.getText().toLowerCase(Locale.getDefault());
460 | for (String composer : composers)
461 | {
462 | if (composer.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
463 | {
464 | results.add (composer);
465 | }
466 | if (results.size() >= max) break;
467 | }
468 |
469 | return results;
470 | }
471 |
472 | }
473 |
474 |
475 |
476 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/Errors.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 | package net.kevinboone.androidmusicplayer;
7 |
8 | /** This class contains error codes and methods to format then as text. */
9 | public class Errors
10 | {
11 | public static final int ERR_OK = 0;
12 | public static final int ERR_PL_EMPTY = 1;
13 | public static final int ERR_PL_RANGE = 2;
14 | public static final int ERR_NO_ALBUMS = 3;
15 | public static final int ERR_ALREADY_AT_START_OF_PLAYLIST = 4;
16 | public static final int ERR_ALREADY_AT_END_OF_PLAYLIST = 5;
17 | public static final int ERR_IO = 6;
18 |
19 | /** Gets the string corresponding to an error code. */
20 | public static String perror (int errorCode)
21 | {
22 | switch (errorCode)
23 | {
24 | case ERR_OK: return "OK";
25 | case ERR_PL_EMPTY: return "Playlist empty";
26 | case ERR_PL_RANGE: return "Playlist index out of range";
27 | case ERR_NO_ALBUMS: return "No albums found in media catalogue";
28 | case ERR_ALREADY_AT_START_OF_PLAYLIST:
29 | return "Already at start of playlist";
30 | case ERR_ALREADY_AT_END_OF_PLAYLIST:
31 | return "Already at end of playlist";
32 | case ERR_IO:
33 | return "IO Error";
34 | }
35 | return "Unknown error";
36 | }
37 |
38 |
39 |
40 | }
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/Player.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 | import java.util.*;
9 | import java.text.*;
10 | import java.io.*;
11 | import java.net.*;
12 | import android.os.*;
13 | import android.content.*;
14 | import android.graphics.*;
15 | import android.media.MediaPlayer;
16 | import android.util.Log;
17 | import android.media.*;
18 | import android.media.audiofx.*;
19 |
20 | public class Player implements
21 | MediaPlayer.OnCompletionListener,
22 | AudioManager.OnAudioFocusChangeListener
23 | {
24 | private MediaPlayer mediaPlayer = new MediaPlayer();
25 | private String currentPlaybackUri = null; //File
26 | private TrackInfo currentPlaybackTrackInfo = null;
27 | protected List playlist = new Vector();
28 | protected int currentPlaylistIndex = -1;
29 | private Equalizer eq = null;
30 | private BassBoost bb = null;
31 | public static final int MAX_EQ_BANDS = 10;
32 | protected AudioDatabase audioDatabase = null;
33 | private Context context;
34 |
35 | /** Constructor. */
36 | public Player (Context context)
37 | {
38 | this.context = context;
39 | mediaPlayer.setOnCompletionListener (this);
40 | audioDatabase = new AudioDatabase();
41 | RemoteControlReceiver.setPlayer (this);
42 | audioDatabase.scan (context);
43 | try
44 | {
45 | // I'm told that these constructors can fail. If they do,
46 | // leave them as null. Other things will fail later, but
47 | // at least the service will start up, and some things
48 | // might still work
49 | eq = new Equalizer (0, mediaPlayer.getAudioSessionId());
50 | bb = new BassBoost (0, mediaPlayer.getAudioSessionId());
51 | }
52 | catch (Throwable e)
53 | {
54 | Log.w ("AMS", "Can't initialize effects: " + e.toString());
55 | }
56 | }
57 |
58 | public void stop()
59 | {
60 | mediaPlayer.reset();
61 | releaseAudioFocus();
62 | currentPlaybackUri = null;
63 | currentPlaybackTrackInfo = null;
64 | }
65 |
66 | @Override
67 | public void onAudioFocusChange (int focusChange)
68 | {
69 | }
70 |
71 |
72 | public void getAudioFocus()
73 | {
74 | AudioManager am = (AudioManager) context.getSystemService
75 | (Context.AUDIO_SERVICE);
76 | am.requestAudioFocus(this, AudioManager.STREAM_MUSIC,
77 | AudioManager.AUDIOFOCUS_GAIN);
78 | am.registerMediaButtonEventReceiver (new ComponentName
79 | (context.getPackageName(), RemoteControlReceiver.class.getName()));
80 | }
81 |
82 |
83 | public void releaseAudioFocus()
84 | {
85 | AudioManager am = (AudioManager) context.getSystemService
86 | (Context.AUDIO_SERVICE);
87 | am.abandonAudioFocus (this);
88 | am.unregisterMediaButtonEventReceiver (new ComponentName
89 | (context.getPackageName(), RemoteControlReceiver.class.getName()));
90 | }
91 |
92 |
93 | protected void playNextInPlaylist ()
94 | throws PlayerException
95 | {
96 | if (playlist.size() > 0 && currentPlaylistIndex < playlist.size() - 1)
97 | {
98 | movePlaylistIndexForward();
99 | playCurrentPlaylistItem();
100 | }
101 | }
102 |
103 |
104 | protected void playPrevInPlaylist ()
105 | throws PlayerException
106 | {
107 | if (playlist.size() > 0 && currentPlaylistIndex > 0)
108 | {
109 | movePlaylistIndexBack();
110 | playCurrentPlaylistItem();
111 | }
112 | }
113 |
114 |
115 | /** Invoked by the Android player when playback of an item is complete.
116 | Try to advance to the next playlist item. */
117 | @Override
118 | public void onCompletion (MediaPlayer mp)
119 | {
120 | currentPlaybackUri = null; // set current URI so it' s clear we
121 | // aren't just paused
122 | currentPlaybackTrackInfo = null;
123 | releaseAudioFocus();
124 | Log.w ("AMP", "Playback completed: " + currentPlaybackUri);
125 | if (playlist.size() > 0 && currentPlaylistIndex < playlist.size() - 1)
126 | {
127 | try
128 | {
129 | playNextInPlaylist();
130 | }
131 | catch (Exception e)
132 | {
133 | Log.w ("AMS", e.toString());
134 | }
135 | }
136 | }
137 |
138 |
139 | public void setEqEnabled (boolean enabled)
140 | {
141 | eq.setEnabled (enabled); //TOD null check
142 | }
143 |
144 |
145 | public boolean getEqEnabled()
146 | {
147 | return eq.getEnabled(); //TOD null check
148 | }
149 |
150 |
151 | public void setEqBandLevel (int band, int level)
152 | {
153 | eq.setBandLevel ((short)band, (short)level);
154 | }
155 |
156 |
157 | public void setBBEnabled (boolean enabled)
158 | {
159 | bb.setEnabled (enabled);
160 | }
161 |
162 |
163 | public boolean getBBEnabled ()
164 | {
165 | return bb.getEnabled ();
166 | }
167 |
168 |
169 | public void setBBStrength (int strength)
170 | {
171 | bb.setStrength ((short)strength);
172 | }
173 |
174 | /** Bass boost strength, 0-1000. */
175 | public int getBBStrength ()
176 | {
177 | int bbStrength = bb.getRoundedStrength();
178 | return bbStrength;
179 | }
180 |
181 |
182 | public String getEqBandFreqRange (int band)
183 | {
184 | int[] fRange = eq.getBandFreqRange ((short)band);
185 | String sFreqRange = AndroidEqUtil.formatBandLabel (fRange);
186 | return sFreqRange;
187 | }
188 |
189 |
190 | public int getEqBandLevel (int band)
191 | {
192 | int level = (int)eq.getBandLevel ((short)band);
193 | return level;
194 | }
195 |
196 |
197 | public int getEqMinLevel()
198 | {
199 | short r[] = eq.getBandLevelRange();
200 | int minLevel = r[0];
201 | return minLevel;
202 | }
203 |
204 |
205 | public int getEqMaxLevel()
206 | {
207 | short r[] = eq.getBandLevelRange();
208 | int maxLevel = r[1];
209 | return maxLevel;
210 | }
211 |
212 |
213 | public int getEqNumberOfBands()
214 | {
215 | int numBands = eq.getNumberOfBands ();
216 | return numBands;
217 | }
218 |
219 |
220 | public void pause ()
221 | {
222 | mediaPlayer.pause();
223 | }
224 |
225 |
226 | public void clearPlaylist ()
227 | {
228 | mediaPlayer.reset();
229 | releaseAudioFocus();
230 | playlist = new Vector();
231 | currentPlaybackUri = null;
232 | currentPlaybackTrackInfo = null;
233 | }
234 |
235 |
236 | public void shufflePlaylist()
237 | {
238 | Collections.shuffle (playlist);
239 | }
240 |
241 |
242 | public void movePlaylistIndexBack()
243 | throws PlayerException
244 | {
245 | if (playlist.size() == 0)
246 | throw new PlaylistEmptyException();
247 |
248 | if (currentPlaylistIndex <= 0)
249 | throw new AlreadyAtStartOfPlaylistException();
250 |
251 | currentPlaylistIndex--;
252 | }
253 |
254 |
255 | public void movePlaylistIndexForward()
256 | throws PlayerException
257 | {
258 | if (playlist.size() == 0)
259 | throw new PlaylistEmptyException();
260 |
261 | if (currentPlaylistIndex >= playlist.size() - 1)
262 | throw new AlreadyAtEndOfPlaylistException();
263 |
264 | currentPlaylistIndex++;
265 | }
266 |
267 |
268 | public void playCurrentPlaylistItem ()
269 | throws PlayerException
270 | {
271 | if (playlist.size() == 0)
272 | throw new PlaylistEmptyException();
273 | if (currentPlaylistIndex < 0)
274 | currentPlaylistIndex = 0;
275 | playInPlaylist (currentPlaylistIndex);
276 | }
277 |
278 |
279 | public void playInPlaylist (int index)
280 | throws PlayerException
281 | {
282 | if (playlist.size() == 0)
283 | throw new PlaylistEmptyException();
284 |
285 | if (index < 0 || index >= playlist.size())
286 | throw new PlaylistIndexOutOfRangeException();
287 |
288 | String uri = playlist.get (index).uri;
289 | currentPlaylistIndex = index;
290 | playFileNow (uri);
291 | }
292 |
293 |
294 | public int playAlbumNow (String album)
295 | throws PlayerException
296 | {
297 | List albumURIs = audioDatabase.getAlbumURIs (context, album);
298 | int count = 0;
299 | clearPlaylist();
300 | for (String uri : albumURIs)
301 | {
302 | TrackInfo ti = audioDatabase.getTrackInfo (context, uri);
303 | playlist.add (ti);
304 | count++;
305 | }
306 |
307 | playFromStartOfPlaylist();
308 | return count;
309 | }
310 |
311 |
312 | /* TODO: handle non-existent album */
313 | public int addAlbumToPlaylist (String album)
314 | throws PlayerException
315 | {
316 | List albumURIs = audioDatabase.getAlbumURIs (context, album);
317 | int count = 0;
318 | for (String uri : albumURIs)
319 | {
320 | TrackInfo ti = audioDatabase.getTrackInfo (context, uri);
321 | playlist.add (ti);
322 | count++;
323 | }
324 | return count;
325 | }
326 |
327 |
328 | public void play ()
329 | throws PlayerException
330 | {
331 | if (currentPlaybackUri != null)
332 | {
333 | // We are paused (or even plaing), not stopped
334 | getAudioFocus();
335 | mediaPlayer.start();
336 | }
337 | else
338 | playFromStartOfPlaylist ();
339 | }
340 |
341 |
342 | public int getCurrentPlaybackPositionMsec()
343 | {
344 | int position = mediaPlayer.getCurrentPosition();
345 | return position;
346 | }
347 |
348 |
349 | public int getCurrentPlaybackDurationMsec()
350 | {
351 | int duration = mediaPlayer.getDuration();
352 | return duration;
353 | }
354 |
355 |
356 | /** May be a file or a content: URI. Will be null if nothing is playing. */
357 | public String getCurrentPlaybackUri ()
358 | {
359 | return currentPlaybackUri;
360 | }
361 |
362 | /** Note that "paused" is not playing. */
363 | public boolean isPlaying ()
364 | {
365 | return mediaPlayer.isPlaying();
366 | }
367 |
368 |
369 | /** May return null if nothing is playing, or a value with meaningless
370 | contents -- check playback status as well. */
371 | public TrackInfo getCurrentPlaybackTrackInfo ()
372 | {
373 | return currentPlaybackTrackInfo;
374 | }
375 |
376 | public List getPlaylist()
377 | {
378 | return playlist;
379 | }
380 |
381 | /**
382 | Adds the specified filesystem item, which might be a directory,
383 | to the playlist, and return the number of items added.
384 | */
385 | public int addFileOrDirectoryToPlaylist (String path)
386 | throws PlayerException
387 | {
388 | File f = new File (path);
389 | if (f.isDirectory ())
390 | {
391 | String[] list = f.list();
392 | int count = 0;
393 | for (String name : list)
394 | {
395 | String cand = path + "/" + name;
396 | File f2 = new File (cand);
397 | if (isPlayableFile (f2.toString()))
398 | {
399 | TrackInfo ti = audioDatabase.getTrackInfo (context, cand);
400 | playlist.add (ti);
401 | count++;
402 | }
403 | }
404 | return count;
405 | }
406 | else
407 | {
408 | TrackInfo ti = audioDatabase.getTrackInfo (context, path);
409 | playlist.add (ti);
410 | return 1;
411 | }
412 | }
413 |
414 |
415 | public void playFromStartOfPlaylist ()
416 | throws PlayerException
417 | {
418 | playInPlaylist (0);
419 | }
420 |
421 | /**
422 | Start playback of the file, and set the currentUri.
423 | */
424 | public void playFileNow (String uri)
425 | throws PlayerException
426 | {
427 | mediaPlayer.reset();
428 | try
429 | {
430 | if (uri.startsWith ("content:"))
431 | {
432 | android.net.Uri contentUri = android.net.Uri.parse (uri);
433 | mediaPlayer.setDataSource (context, contentUri);
434 | }
435 | else
436 | {
437 | String filename = uri;
438 | mediaPlayer.setDataSource (filename);
439 | }
440 | mediaPlayer.prepare();
441 | getAudioFocus();
442 | currentPlaybackUri = uri;
443 | currentPlaybackTrackInfo = audioDatabase.getTrackInfo (context, uri);
444 | mediaPlayer.start();
445 | }
446 | catch (IOException e)
447 | {
448 | mediaPlayer.reset();
449 | currentPlaybackUri = null;
450 | throw new PlayerIOException (e.toString());
451 | }
452 | }
453 |
454 |
455 | public void scanAudioDatabase ()
456 | {
457 | audioDatabase.scan (context);
458 | }
459 |
460 | public Set getAlbums()
461 | {
462 | return audioDatabase.getAlbums();
463 | }
464 |
465 |
466 | public Set getArtists()
467 | {
468 | return audioDatabase.getArtists();
469 | }
470 |
471 |
472 | public Set getGenres()
473 | {
474 | return audioDatabase.getGenres();
475 | }
476 |
477 |
478 | public Set getComposers()
479 | {
480 | return audioDatabase.getComposers();
481 | }
482 |
483 |
484 | public byte[] getEmbeddedPictureForTrackUri (String uri)
485 | {
486 | return audioDatabase.getEmbeddedPicture (context, uri);
487 | }
488 |
489 |
490 | public String getFilePathFromContentUri (android.net.Uri uri)
491 | {
492 | return audioDatabase.getFilePathFromContentUri (context, uri);
493 | }
494 |
495 |
496 | public List getAlbumTrackUris (String album)
497 | {
498 | return audioDatabase.getAlbumURIs (context, album);
499 | }
500 |
501 |
502 | public Set getAlbumsByArtist (String artist)
503 | {
504 | return audioDatabase.getAlbumsByArtist (context, artist);
505 | }
506 |
507 |
508 | public Set getAlbumsByComposer (String artist)
509 | {
510 | return audioDatabase.getAlbumsByComposer (context, artist);
511 | }
512 |
513 |
514 | public Set getAlbumsByGenre (String genre)
515 | {
516 | return audioDatabase.getAlbumsByGenre (context, genre);
517 | }
518 |
519 |
520 | public TrackInfo getTrackInfo (String uri)
521 | {
522 | return audioDatabase.getTrackInfo (context, uri);
523 | }
524 |
525 |
526 | /** Returns true if the filename suggests mp3, aac, etc. */
527 | static public boolean isPlayableFile (String name)
528 | {
529 | int p = name.lastIndexOf ('.');
530 | if (p <= 0) return false;
531 | String ext = name.substring (p);
532 | if (".mp3".equalsIgnoreCase (ext)) return true;
533 | if (".m4a".equalsIgnoreCase (ext)) return true;
534 | if (".aac".equalsIgnoreCase (ext)) return true;
535 | if (".ogg".equalsIgnoreCase (ext)) return true;
536 | if (".wma".equalsIgnoreCase (ext)) return true;
537 | if (".flac".equalsIgnoreCase (ext)) return true;
538 | return false;
539 | }
540 |
541 | public Set findTracks (SearchSpec search, int start, int num)
542 | {
543 | return audioDatabase.findTracks (context, search, start, num);
544 | }
545 |
546 |
547 | public int getApproxNumTracks ()
548 | {
549 | return audioDatabase.getApproxNumTracks();
550 | }
551 |
552 |
553 | public Set getMatchingAlbums (SearchSpec ss, int max)
554 | {
555 | return audioDatabase.getMatchingAlbums (ss, max);
556 | }
557 |
558 |
559 | public Set getMatchingArtists (SearchSpec ss, int max)
560 | {
561 | return audioDatabase.getMatchingArtists (ss, max);
562 | }
563 |
564 | public Set getMatchingComposers (SearchSpec ss, int max)
565 | {
566 | return audioDatabase.getMatchingComposers (ss, max);
567 | }
568 |
569 | }
570 |
571 |
572 |
573 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/PlayerException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class PlayerException extends Exception
10 | {
11 | private int code = 0;
12 |
13 | public PlayerException (int code, String message)
14 | {
15 | super (message);
16 | this.code = code;
17 | }
18 |
19 | public PlayerException (int code)
20 | {
21 | super (Errors.perror (code));
22 | this.code = code;
23 | }
24 |
25 | public int getCode() { return code; }
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/PlayerIOException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class PlayerIOException extends PlayerException
10 | {
11 | public PlayerIOException (String message)
12 | {
13 | super (Errors.ERR_IO, message);
14 | }
15 |
16 | }
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/PlaylistEmptyException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class PlaylistEmptyException extends PlayerException
10 | {
11 | public PlaylistEmptyException ()
12 | {
13 | super (Errors.ERR_PL_EMPTY);
14 | }
15 |
16 | }
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/PlaylistIndexOutOfRangeException.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class PlaylistIndexOutOfRangeException extends PlayerException
10 | {
11 | public PlaylistIndexOutOfRangeException ()
12 | {
13 | super (Errors.ERR_PL_RANGE);
14 | }
15 |
16 | }
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/RemoteControlReceiver.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 | import java.util.*;
9 | import java.io.*;
10 | import java.net.*;
11 | import android.content.*;
12 | import android.media.MediaPlayer;
13 | import android.util.Log;
14 | import android.view.*;
15 | import android.media.*;
16 | import android.annotation.*;
17 |
18 | @SuppressLint("StaticFieldLeak")
19 | public class RemoteControlReceiver extends BroadcastReceiver
20 | {
21 | protected static Player player = null;
22 |
23 | public static void setPlayer (Player _player)
24 | {
25 | player = _player;
26 | }
27 |
28 | @Override
29 | public void onReceive(Context context, Intent intent)
30 | {
31 | try
32 | {
33 | if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()))
34 | {
35 | KeyEvent event = (KeyEvent)intent.getParcelableExtra
36 | (Intent.EXTRA_KEY_EVENT);
37 | if (event.getAction() == event.ACTION_DOWN)
38 | {
39 | if (KeyEvent.KEYCODE_MEDIA_PLAY == event.getKeyCode())
40 | {
41 | player.play();
42 | }
43 | else if (KeyEvent.KEYCODE_MEDIA_NEXT == event.getKeyCode())
44 | {
45 | player.playNextInPlaylist();
46 | }
47 | else if (KeyEvent.KEYCODE_MEDIA_PAUSE == event.getKeyCode())
48 | {
49 | player.pause();
50 | }
51 | else if (KeyEvent.KEYCODE_MEDIA_STOP == event.getKeyCode())
52 | {
53 | player.stop();
54 | }
55 | else if (KeyEvent.KEYCODE_MEDIA_PREVIOUS == event.getKeyCode())
56 | {
57 | player.playPrevInPlaylist();
58 | }
59 | }
60 | }
61 | }
62 | catch (PlayerException e)
63 | {
64 | Log.w ("AMS", "Caught exception in remote control handler: " + e);
65 | }
66 | }
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/SearchSpec.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | public class SearchSpec
10 | {
11 | private String text;
12 |
13 | public SearchSpec (String text)
14 | {
15 | this.text = text;
16 | }
17 |
18 | public String getText() { return text; }
19 |
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/TrackInfo.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 |
9 | /** A simple data structure for audio track information. */
10 | public class TrackInfo
11 | {
12 | public String uri;
13 | public String title;
14 | public String artist;
15 | public String album;
16 | public String composer;
17 | public String trackNumber;
18 | public String genre;
19 |
20 | /** Constructor takes a uri argument to ensure that at least the uri is
21 | set, even if nothing else is. */
22 | public TrackInfo (String uri)
23 | {
24 | this.uri = uri;
25 | }
26 |
27 | private TrackInfo ()
28 | {
29 | }
30 |
31 | /** If we don't have a proper title for the item, strip the path and
32 | extension from the URI and use that instead. */
33 | public static String makeTitleFromUri (String uri)
34 | {
35 | int p = uri.lastIndexOf ('/');
36 | if (p >= 0)
37 | uri = uri.substring (p + 1);
38 | p = uri.lastIndexOf ('.');
39 | if (p > 0)
40 | uri = uri.substring (0, p);
41 | return uri;
42 | }
43 |
44 | }
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/textutils/EscapeUtils.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.textutils;
8 |
9 | public class EscapeUtils
10 | {
11 | /** For data sent back to the browser, which will have strings enclosed
12 | in single quotes, convert ' to \'. */
13 | public static String escapeJSON (String s)
14 | {
15 | StringBuffer sb = new StringBuffer();
16 | int l = s.length();
17 | for (int i = 0; i < l; i++)
18 | {
19 | char c = s.charAt(i);
20 | if (c == '\'')
21 | sb.append ("\\'");
22 | else
23 | sb.append (c);
24 | }
25 | return new String (sb);
26 | }
27 |
28 | /** Escape single-quotes in SQL data values. */
29 | public static String escapeSQL (String s)
30 | {
31 | StringBuffer sb = new StringBuffer();
32 | int l = s.length();
33 | for (int i = 0; i < l; i++)
34 | {
35 | char c = s.charAt(i);
36 | if (c == '\'')
37 | sb.append ("''");
38 | else
39 | sb.append (c);
40 | }
41 | return new String (sb);
42 | }
43 |
44 | }
45 |
46 |
47 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-ldpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-ldpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_next.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_next.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_pause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_pause.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_play.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_prev.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_prev.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_settings.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_shutdown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_shutdown.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/button_stop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_stop.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/layout/main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
14 |
16 |
17 |
24 |
31 |
32 |
33 |
40 |
47 |
48 |
49 |
56 |
63 |
64 |
65 |
72 |
79 |
80 |
81 |
88 |
95 |
96 |
97 |
98 |
102 |
103 |
108 |
109 |
113 |
114 |
121 |
127 |
133 |
139 |
145 |
151 |
157 |
163 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Android music player
7 |
8 |
9 |
10 |
11 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Android Music Server
4 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | api.example.com(to be adjusted)
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/preferences.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
17 |
18 |
27 |
28 |
37 |
38 |
47 |
48 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | buildscript {
3 | repositories {
4 | google()
5 | maven {
6 | url "https://maven.google.com"
7 | }
8 | jcenter()
9 | }
10 | dependencies {
11 | classpath 'com.android.tools.build:gradle:7.4.0'
12 | }
13 | }
14 |
15 | allprojects {
16 | repositories {
17 | google()
18 | jcenter()
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/de/short_description.txt:
--------------------------------------------------------------------------------
1 | ein Webinterface zum Abspielen von Musik auf Android-Geräten
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Kevin's Music Server for Android provides a web browser interface to control playback of audio files stored on most modern (4.x-11.0) Android devices. This allows the Android device to be used as a music server, in multi-room audio applications, among other things. I normally keep my Android phone docked, with a permanent USB connection to an audio DAC. This arrangement produces good quality audio playback, but I don't always have the pone within reach. It's awkward to fiddle with the little screen when it's docked, anyway. Providing a web interface – albeit a crude one – allows me to control playback from a web browser.
2 |
3 | Audio tracks can be selected using the browser from a list of albums, or directly from the filesystem (but see notes below). You can restrict the album listing to particular genres or particular artists rather than displaying all albums on the same page. Album cover art images can be displayed.
4 |
5 | Features:
6 |
7 | * Simple web interface – works on most desktop web browsers and many mobile browsers
8 | * Integrates with the Android media catalogue – browse by album, artist, genre, composer, or track
9 | * Supports file/folder browsing
10 | * Media catalogue text search
11 | * Equalizer
12 | * Cover art (both baked-in and album-folder images)
13 | * Playback control by headset or remote control
14 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/icon.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | a web interface to music playback on Android devices
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jan 14 11:39:33 GMT 2023
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/local.properties:
--------------------------------------------------------------------------------
1 | ## This file must *NOT* be checked into Version Control Systems,
2 | # as it contains information specific to your local configuration.
3 | #
4 | # Location of the SDK. This is only used by Gradle.
5 | #
6 | #Sat Jan 14 11:39:33 GMT 2023
7 | sdk.dir=/home/kevin/Android/Sdk
8 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------