pifi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/INSTALL.md +63 -0
  3. data/LICENSE +674 -0
  4. data/README.md +140 -0
  5. data/bin/pifi +24 -0
  6. data/config.ru +4 -0
  7. data/docs/icon/license.pdf +737 -1
  8. data/lib/pifi.rb +2 -0
  9. data/lib/pifi/controllers/application_controller.rb +16 -0
  10. data/lib/pifi/controllers/index_controller.rb +33 -0
  11. data/lib/pifi/controllers/player_controller.rb +40 -0
  12. data/lib/pifi/lib/config_getter.rb +44 -0
  13. data/lib/pifi/lib/lang_chooser.rb +27 -0
  14. data/lib/pifi/lib/player.rb +156 -0
  15. data/lib/pifi/lib/streams_getter.rb +27 -0
  16. data/lib/pifi/lib/utils.rb +8 -0
  17. data/lib/pifi/public/android-chrome-192x192.png +0 -0
  18. data/lib/pifi/public/android-chrome-384x384.png +0 -0
  19. data/lib/pifi/public/apple-touch-icon.png +0 -0
  20. data/lib/pifi/public/browserconfig.xml +9 -0
  21. data/lib/pifi/public/css/app.css +143 -0
  22. data/lib/pifi/public/favicon-16x16.png +0 -0
  23. data/lib/pifi/public/favicon-32x32.png +0 -0
  24. data/lib/pifi/public/favicon.ico +0 -0
  25. data/lib/pifi/public/js/app.js +298 -0
  26. data/lib/pifi/public/js/lang/en-us.js +19 -0
  27. data/lib/pifi/public/js/lang/fr-fr.js +19 -0
  28. data/lib/pifi/public/js/lang/nl-nl.js +19 -0
  29. data/lib/pifi/public/js/lang/pt-br.js +19 -0
  30. data/lib/pifi/public/mstile-144x144.png +0 -0
  31. data/lib/pifi/public/mstile-150x150.png +0 -0
  32. data/lib/pifi/public/mstile-310x150.png +0 -0
  33. data/lib/pifi/public/mstile-310x310.png +0 -0
  34. data/lib/pifi/public/mstile-70x70.png +0 -0
  35. data/lib/pifi/public/safari-pinned-tab.svg +61 -0
  36. data/lib/pifi/public/site.webmanifest +19 -0
  37. data/lib/pifi/public/vendor/css/bootstrap-theme.css +587 -0
  38. data/lib/pifi/public/vendor/css/bootstrap-theme.css.map +1 -0
  39. data/lib/pifi/public/vendor/css/bootstrap-theme.min.css +6 -0
  40. data/lib/pifi/public/vendor/css/bootstrap-theme.min.css.map +1 -0
  41. data/lib/pifi/public/vendor/css/bootstrap.css +6757 -0
  42. data/lib/pifi/public/vendor/css/bootstrap.css.map +1 -0
  43. data/lib/pifi/public/vendor/css/bootstrap.min.css +6 -0
  44. data/lib/pifi/public/vendor/css/bootstrap.min.css.map +1 -0
  45. data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.eot +0 -0
  46. data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.svg +288 -0
  47. data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.ttf +0 -0
  48. data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.woff +0 -0
  49. data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.woff2 +0 -0
  50. data/lib/pifi/public/vendor/js/bootstrap.js +2377 -0
  51. data/lib/pifi/public/vendor/js/bootstrap.min.js +7 -0
  52. data/lib/pifi/public/vendor/js/jquery.min.js +4 -0
  53. data/lib/pifi/public/vendor/js/npm.js +13 -0
  54. data/lib/pifi/views/index.erb +91 -0
  55. data/lib/pifi/views/layout.erb +35 -0
  56. metadata +169 -0
data/lib/pifi.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "pifi/controllers/index_controller"
2
+ require "pifi/controllers/player_controller"
@@ -0,0 +1,16 @@
1
+ require "pifi/lib/config_getter"
2
+ require "pifi/lib/streams_getter"
3
+ require "sinatra/base"
4
+
5
+ module PiFi
6
+ class ApplicationController < Sinatra::Base
7
+ set ConfigGetter.new.config
8
+ set :streams, StreamsGetter.new(settings.streams_file, settings.streamsp_file).streams
9
+
10
+ set :root, File.expand_path("../../", __FILE__)
11
+
12
+ configure :production do
13
+ set :static, settings.serve_static
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ require "pifi/controllers/application_controller"
2
+ require "pifi/lib/lang_chooser"
3
+
4
+ module PiFi
5
+ class IndexController < ApplicationController
6
+ def title
7
+ title = "PiFi Radio"
8
+ settings.production? ? title : "[#{settings.environment.capitalize}] #{title}"
9
+ end
10
+
11
+ def special_ip?
12
+ # Try to get remote IP if behind reverse-proxy
13
+ ip = env["HTTP_X_FORWARDED_FOR"] || request.ip
14
+ settings.special_ips.include?(ip)
15
+ end
16
+
17
+ def streams_set
18
+ special_ip? ? settings.streams[:all] : settings.streams[:pub]
19
+ end
20
+
21
+ def lang
22
+ LangChooser.new(env["HTTP_ACCEPT_LANGUAGE"]).lang
23
+ end
24
+
25
+ def play_local?
26
+ settings.play_local
27
+ end
28
+
29
+ get "/" do
30
+ erb :index
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ require "pifi/controllers/application_controller"
2
+ require "pifi/lib/player"
3
+
4
+ module PiFi
5
+ class PlayerController < ApplicationController
6
+ ALLOWED_METHODS=["play", "stop", "change_vol", "play_radios", "play_urls", "play_random"]
7
+
8
+ def player
9
+ @@player
10
+ end
11
+
12
+ @@player = Player.new(settings.streams[:all],
13
+ host = settings.mpd_host,
14
+ port = settings.mpd_port,
15
+ password = settings.mpd_password)
16
+
17
+ get "/" do
18
+ content_type :json
19
+ cache_control :no_cache
20
+ player.state.to_json
21
+ end
22
+
23
+ post "/" do
24
+ content_type :text
25
+
26
+ method = params[:method]
27
+ args = params[:params]
28
+ halt 400, "Invalid method" unless ALLOWED_METHODS.include?(method)
29
+
30
+ begin
31
+ result = player.public_send(method, *args)
32
+ rescue ArgumentError, MPD::NotFound => e
33
+ halt 400, e.message.delete_prefix("[] ")
34
+ rescue MPD::PermissionError, MPD::IncorrectPassword => e
35
+ halt 403, e.message.delete_prefix("[] ")
36
+ end
37
+ result.to_s
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,44 @@
1
+ require "pifi/lib/utils"
2
+
3
+ module PiFi
4
+ class ConfigGetter
5
+ include Utils
6
+
7
+ PATH = "/etc/pifi.json"
8
+ DEFAULT_KEYS = {
9
+ "mpd_host" => "127.0.0.1",
10
+ "mpd_port" => "6600",
11
+ "mpd_password" => "",
12
+ "streams_file" => "/etc/pifi_streams.json",
13
+ "streamsp_file" => "",
14
+ "special_ips" => "",
15
+ "play_local" => false,
16
+ "serve_static" => true
17
+ }
18
+
19
+ def config
20
+ @config ||= parse_config
21
+ end
22
+
23
+ private
24
+
25
+ def parse_config
26
+ if File.exists?(PATH)
27
+ warn "Config found at ${PATH}."
28
+ config = file_to_hash(PATH)
29
+ else
30
+ warn "Config not found. Using defaults."
31
+ config = {}
32
+ end
33
+ config = DEFAULT_KEYS.merge(config)
34
+ check_errors(config)
35
+
36
+ config
37
+ end
38
+
39
+ def check_errors(config)
40
+ invalid = config.keys.reject { |key| DEFAULT_KEYS.include?(key) }
41
+ warn "Invalid keys in config file: #{invalid}" unless invalid.empty?
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ module PiFi
2
+ class LangChooser
3
+ DEFAULT = "en-us"
4
+ LANG_DIR = "app/public/js/lang/*.js"
5
+
6
+ def initialize(http_accept_language)
7
+ @http_accept = http_accept_language
8
+ end
9
+
10
+ def lang
11
+ lang = accept.find { |e| avail.include?(e) }
12
+ lang || DEFAULT
13
+ end
14
+
15
+
16
+ private
17
+
18
+ def avail
19
+ @@avail ||= Dir.glob(LANG_DIR).map { |file| File.basename(file, ".*") }
20
+ end
21
+
22
+ def accept
23
+ return [] if @http_accept.nil?
24
+ @http_accept.split(";")[0].split(",").map(&:downcase)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,156 @@
1
+ require "ruby-mpd"
2
+
3
+ module PiFi
4
+ class Player
5
+ class VolNaError < StandardError
6
+ def message
7
+ "Volume is not available"
8
+ end
9
+ end
10
+
11
+ CROSSFADE = 5
12
+ DEFAULT_TITLE_LOCAL = "Music"
13
+ DEFAULT_TITLE_STREAM = "Streaming"
14
+ DEFAULT_HOST = "localhost"
15
+ DEFAULT_PORT = "6600"
16
+
17
+ def initialize(streams, host=DEFAULT_HOST, port=DEFAULT_PORT, password=nil)
18
+ @streams = streams
19
+ @mpd = MPD.new(host, port, { callbacks: true })
20
+ @mpd.connect
21
+ @mpd.password(password) unless password.to_s.empty?
22
+
23
+ define_callbacks
24
+ end
25
+
26
+ def state
27
+ { playing: @playing,
28
+ title: @title,
29
+ artist: @artist,
30
+ local: @local,
31
+ elapsed: @elapsed,
32
+ length: @length,
33
+ vol: @vol,
34
+ con_mpd: @con_mpd }
35
+ end
36
+
37
+ def play
38
+ # Assigning is quicker than callback
39
+ @playing = true if @mpd.play
40
+ end
41
+
42
+ def stop
43
+ # Assigning is quicker than callback
44
+ @playing = false if @mpd.stop
45
+ end
46
+
47
+ def change_vol(delta)
48
+ raise ArgumentError, "Invalid argument" unless delta =~ /^[+-]\d{1,2}$/
49
+ # We get @vol=-1 when PulseAudio sink is closed
50
+ raise VolNaError if @vol < 0
51
+
52
+ @mpd.send_command("volume", delta);
53
+ # This is more up-to-date than @vol
54
+ @mpd.volume
55
+ end
56
+
57
+ def play_radios(*names)
58
+ raise ArgumentError, "Argument required" if names.empty?
59
+
60
+ urls = []
61
+ names.each do |name|
62
+ url = @streams[name]
63
+ # nil: key not found; empty: it's a radio category
64
+ raise ArgumentError, "Invalid radio name: #{name}" if url.nil? || url.empty?
65
+
66
+ urls << url
67
+ end
68
+
69
+ play_urls(*urls)
70
+ end
71
+
72
+ def play_urls(*urls)
73
+ raise ArgumentError, "Argument required" if urls.empty?
74
+ raise ArgumentError, "Empty url given" if urls.any?(&:empty?)
75
+
76
+ @mpd.clear
77
+ urls.each { |url| @mpd.add(url) }
78
+ @mpd.random=(false)
79
+ @mpd.play
80
+ end
81
+
82
+ def play_random
83
+ if @playing && @local
84
+ @mpd.next
85
+ else
86
+ @mpd.clear
87
+ @mpd.add("/")
88
+ @mpd.random=(true)
89
+ @mpd.crossfade=(CROSSFADE)
90
+ @mpd.play
91
+ end
92
+ end
93
+
94
+
95
+ private
96
+
97
+ def define_callbacks
98
+ @mpd.on(:state, &method(:set_state))
99
+ @mpd.on(:time, &method(:set_time))
100
+ @mpd.on(:song, &method(:set_song))
101
+ @mpd.on(:volume, &method(:set_vol))
102
+ @mpd.on(:connection, &method(:set_con_mpd))
103
+ end
104
+
105
+ def set_state(state)
106
+ @playing = (state == :play)
107
+ end
108
+
109
+ def set_time(elapsed, length)
110
+ @elapsed = elapsed
111
+ @length = length
112
+ end
113
+
114
+ def set_song(*args)
115
+ # Sometimes we receive no arguments, instead of MPD::Song object
116
+ if args.length == 0
117
+ @local = false
118
+ @title = ""
119
+ @artist = ""
120
+ return
121
+ end
122
+
123
+ song = args[0]
124
+ if song.file.include?("://")
125
+ set_song_stream(song)
126
+ else
127
+ set_song_local(song)
128
+ end
129
+ end
130
+
131
+ def set_song_local(song)
132
+ @local = true
133
+ if song.artist.nil? || song.title.nil?
134
+ @title = DEFAULT_TITLE_LOCAL
135
+ @artist = ""
136
+ else
137
+ @title = song.title
138
+ @artist = song.artist
139
+ end
140
+ end
141
+
142
+ def set_song_stream(song)
143
+ @local = false
144
+ @title = @streams.key(song.file) || DEFAULT_TITLE_STREAM
145
+ @artist = ""
146
+ end
147
+
148
+ def set_vol(vol)
149
+ @vol = vol
150
+ end
151
+
152
+ def set_con_mpd(con_mpd)
153
+ @con_mpd = con_mpd
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,27 @@
1
+ require "pifi/lib/utils"
2
+
3
+ module PiFi
4
+ class StreamsGetter
5
+ include Utils
6
+
7
+ def initialize(path_pub, path_priv)
8
+ @path_pub = path_pub
9
+ @path_priv = path_priv
10
+ end
11
+
12
+ def streams
13
+ @streams ||= parse_streams
14
+ end
15
+
16
+
17
+ private
18
+
19
+ def parse_streams
20
+ pub = file_to_hash(@path_pub)
21
+ priv = @path_priv.empty? ? {} : file_to_hash(@path_priv)
22
+ all = pub.merge(priv)
23
+
24
+ {pub: pub, all: all}
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,8 @@
1
+ module PiFi
2
+ module Utils
3
+ def file_to_hash(path)
4
+ file_content = File.read(path)
5
+ JSON.parse(file_content)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <browserconfig>
3
+ <msapplication>
4
+ <tile>
5
+ <square150x150logo src="/mstile-150x150.png"/>
6
+ <TileColor>#da532c</TileColor>
7
+ </tile>
8
+ </msapplication>
9
+ </browserconfig>
@@ -0,0 +1,143 @@
1
+ .container {
2
+ margin-top: 25px;
3
+ margin-bottom: 30px;
4
+ margin-right: auto;
5
+ margin-left: auto;
6
+ max-width: 350px;
7
+ }
8
+
9
+ /* Hide everything */
10
+ .view, #osd {
11
+ display: none;
12
+ }
13
+
14
+ /* Don't change buttons styles after clicked. */
15
+ .btn:focus {
16
+ background-position: 0;
17
+ border-color: #ccc;
18
+ outline: none;
19
+ }
20
+
21
+ /* player */
22
+ #player, #alert {
23
+ max-width: 320px;
24
+ }
25
+
26
+ #state {
27
+ height: 93px;
28
+ margin-bottom: 10px;
29
+ }
30
+
31
+ #progress {
32
+ float: right;
33
+ }
34
+
35
+ #state-top-line {
36
+ margin-bottom: 0px; /* we want the progress bar to be close */
37
+ }
38
+
39
+ #progress-bar {
40
+ margin-top: 2px;
41
+ height: 2px;
42
+ width: 50%;
43
+ }
44
+
45
+ #title {
46
+ margin-top: 6px; /* 10px - 2px (progress bar) - 2px (progress bar top margin) */
47
+ margin-bottom: 0px;
48
+ }
49
+
50
+ #artist {
51
+ margin-top: 0px;
52
+ }
53
+
54
+ /* Trim string and add suspension points if needed */
55
+ #title, #artist {
56
+ white-space: nowrap;
57
+ overflow: hidden;
58
+ text-overflow: ellipsis;
59
+ }
60
+
61
+ #btn-ps {
62
+ margin-right: 30px;
63
+ }
64
+
65
+ #player-bottom {
66
+ margin-top: 120px;
67
+ }
68
+
69
+ #osd {
70
+ margin-top: 90px;
71
+ }
72
+
73
+ #btn-random {
74
+ float: left;
75
+ width: 120px;
76
+ }
77
+
78
+ /* Only float right if btn-random is present */
79
+ #btn-random ~ #btn-radios {
80
+ float: right;
81
+ }
82
+
83
+ #btn-radios {
84
+ width: 120px;
85
+ }
86
+
87
+
88
+ /* radios list */
89
+ #radios-welcome {
90
+ margin-bottom: 40px;
91
+ }
92
+
93
+ #radios-list {
94
+ box-shadow: none;
95
+ }
96
+
97
+ .list-group-item {
98
+ border-style: none;
99
+ padding-bottom: 7px;
100
+ }
101
+
102
+ .radios-group-title {
103
+ margin-top: 40px;
104
+ padding-top: 10px;
105
+ padding-bottom: 10px;
106
+ padding-left: 15px;
107
+ border-width: 0px 0px 1px 0px; /* just bottom border */
108
+ border-style: solid;
109
+ }
110
+
111
+ #insert {
112
+ margin-top: 45px;
113
+ margin-bottom: 130px;
114
+ padding-left: 15px;
115
+ }
116
+
117
+ #radios-bottom {
118
+ position: fixed;
119
+ bottom: 0px;
120
+ background-color: white;
121
+ width: 350px;
122
+ left: 50%;
123
+ transform: translate(-50%, 0%);
124
+ }
125
+
126
+ #btn-player {
127
+ width: 150px;
128
+ margin-bottom: 20px;
129
+ margin-top: 30px;
130
+ }
131
+
132
+
133
+ /* Alerts */
134
+ #alert {
135
+ padding-top: 40%;
136
+ }
137
+
138
+ /* Trim string and add suspension points if needed */
139
+ #alert-text-more {
140
+ white-space: nowrap;
141
+ overflow: hidden;
142
+ text-overflow: ellipsis;
143
+ }