├── .gitignore ├── .rbenv-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── airstream.gemspec ├── bin ├── airimg └── airstream ├── examples └── imgur-funny-stream ├── features ├── error-handling.feature ├── step_definitions │ └── general_steps.rb └── support │ └── env.rb ├── lib ├── airstream.rb └── airstream │ ├── device.rb │ ├── io.rb │ ├── network.rb │ ├── node.rb │ ├── player.rb │ ├── version.rb │ └── video.rb └── test ├── device_test.rb ├── helper └── bootstrap.rb ├── network_test.rb └── player_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | 3 | coverage 4 | vendor/ 5 | -------------------------------------------------------------------------------- /.rbenv-version: -------------------------------------------------------------------------------- 1 | 2.0.0-p247 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | before_install: 3 | - sudo apt-get update 4 | - sudo apt-get install libavahi-compat-libdnssd-dev 5 | bundler_args: --without development 6 | rvm: 7 | - 1.9.2 8 | - 2.0.0 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | 7 | group :development do 8 | gem 'rake-notes' 9 | end 10 | 11 | group :test do 12 | gem 'minitest' 13 | gem 'coveralls', require: false 14 | gem 'oj' 15 | gem 'aruba' 16 | gem 'json' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | airstream (0.4.10) 5 | airplay (~> 0.2.9) 6 | rack (~> 2.2.3) 7 | ruby-progressbar (~> 1.1.1) 8 | webrick (~> 1.6.1) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | activesupport (6.1.1) 14 | concurrent-ruby (~> 1.0, >= 1.0.2) 15 | i18n (>= 1.6, < 2) 16 | minitest (>= 5.1) 17 | tzinfo (~> 2.0) 18 | zeitwerk (~> 2.3) 19 | airplay (0.2.9) 20 | dnssd (~> 2.0) 21 | net-http-digest_auth (~> 1.2) 22 | net-http-persistent (>= 2.6) 23 | aruba (1.0.4) 24 | childprocess (>= 2.0, < 5.0) 25 | contracts (~> 0.16.0) 26 | cucumber (>= 2.4, < 6.0) 27 | rspec-expectations (~> 3.4) 28 | thor (~> 1.0) 29 | builder (3.2.4) 30 | childprocess (4.0.0) 31 | colored (1.2) 32 | concurrent-ruby (1.1.7) 33 | connection_pool (2.2.3) 34 | contracts (0.16.0) 35 | coveralls (0.8.23) 36 | json (>= 1.8, < 3) 37 | simplecov (~> 0.16.1) 38 | term-ansicolor (~> 1.3) 39 | thor (>= 0.19.4, < 2.0) 40 | tins (~> 1.6) 41 | cucumber (5.2.0) 42 | builder (~> 3.2, >= 3.2.4) 43 | cucumber-core (~> 8.0, >= 8.0.1) 44 | cucumber-create-meta (~> 2.0, >= 2.0.2) 45 | cucumber-cucumber-expressions (~> 10.3, >= 10.3.0) 46 | cucumber-gherkin (~> 15.0, >= 15.0.2) 47 | cucumber-html-formatter (~> 9.0, >= 9.0.0) 48 | cucumber-messages (~> 13.1, >= 13.1.0) 49 | cucumber-wire (~> 4.0, >= 4.0.1) 50 | diff-lcs (~> 1.4, >= 1.4.4) 51 | multi_test (~> 0.1, >= 0.1.2) 52 | sys-uname (~> 1.2, >= 1.2.1) 53 | cucumber-core (8.0.1) 54 | cucumber-gherkin (~> 15.0, >= 15.0.2) 55 | cucumber-messages (~> 13.0, >= 13.0.1) 56 | cucumber-tag-expressions (~> 2.0, >= 2.0.4) 57 | cucumber-create-meta (2.0.4) 58 | cucumber-messages (~> 13.1, >= 13.1.0) 59 | sys-uname (~> 1.2, >= 1.2.1) 60 | cucumber-cucumber-expressions (10.3.0) 61 | cucumber-gherkin (15.0.2) 62 | cucumber-messages (~> 13.0, >= 13.0.1) 63 | cucumber-html-formatter (9.0.0) 64 | cucumber-messages (~> 13.0, >= 13.0.1) 65 | cucumber-messages (13.2.1) 66 | protobuf-cucumber (~> 3.10, >= 3.10.8) 67 | cucumber-tag-expressions (2.0.4) 68 | cucumber-wire (4.0.1) 69 | cucumber-core (~> 8.0, >= 8.0.1) 70 | cucumber-cucumber-expressions (~> 10.3, >= 10.3.0) 71 | cucumber-messages (~> 13.0, >= 13.0.1) 72 | diff-lcs (1.4.4) 73 | dnssd (2.0.1) 74 | docile (1.3.5) 75 | ffi (1.14.2) 76 | i18n (1.8.7) 77 | concurrent-ruby (~> 1.0) 78 | json (2.5.1) 79 | middleware (0.1.0) 80 | minitest (5.14.3) 81 | multi_test (0.1.2) 82 | net-http-digest_auth (1.4.1) 83 | net-http-persistent (4.0.1) 84 | connection_pool (~> 2.2) 85 | oj (3.11.0) 86 | protobuf-cucumber (3.10.8) 87 | activesupport (>= 3.2) 88 | middleware 89 | thor 90 | thread_safe 91 | rack (2.2.3) 92 | rake (13.0.3) 93 | rake-notes (0.2.2) 94 | colored 95 | rake 96 | rspec-expectations (3.10.1) 97 | diff-lcs (>= 1.2.0, < 2.0) 98 | rspec-support (~> 3.10.0) 99 | rspec-support (3.10.1) 100 | ruby-progressbar (1.1.1) 101 | simplecov (0.16.1) 102 | docile (~> 1.1) 103 | json (>= 1.8, < 3) 104 | simplecov-html (~> 0.10.0) 105 | simplecov-html (0.10.2) 106 | sync (0.5.0) 107 | sys-uname (1.2.2) 108 | ffi (~> 1.1) 109 | term-ansicolor (1.7.1) 110 | tins (~> 1.0) 111 | thor (1.0.1) 112 | thread_safe (0.3.6) 113 | tins (1.28.0) 114 | sync 115 | tzinfo (2.0.4) 116 | concurrent-ruby (~> 1.0) 117 | webrick (1.6.1) 118 | zeitwerk (2.4.2) 119 | 120 | PLATFORMS 121 | ruby 122 | 123 | DEPENDENCIES 124 | airstream! 125 | aruba 126 | coveralls 127 | json 128 | minitest 129 | oj 130 | rake 131 | rake-notes 132 | 133 | BUNDLED WITH 134 | 1.17.3 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airstream 2 | 3 | A command line tool for sending videos and images to airplay-compatible devices 4 | (like AppleTV). 5 | 6 | [![Gem Version](https://badge.fury.io/rb/airstream.png)](http://badge.fury.io/rb/airstream) 7 | [![Build Status](https://travis-ci.org/unused/airstream.png?branch=master)](https://travis-ci.org/unused/airstream) 8 | [![Code Climate](https://codeclimate.com/github/unused/airstream.png)](https://codeclimate.com/github/unused/airstream) 9 | [![Coverage Status](https://coveralls.io/repos/unused/airstream/badge.png?branch=master)](https://coveralls.io/r/unused/airstream?branch=master) 10 | [![Dependency Status](https://gemnasium.com/unused/airstream.png)](https://gemnasium.com/unused/airstream) 11 | 12 | The airplay protocol can basically play videos in mpeg-4 (mp4, mov, ts, m4v) 13 | format that are accessible via http. Thus for sending a local file a webserver 14 | will be created, see below. 15 | 16 | Local or remote images can be sent directly to an airplay-device. 17 | 18 | Playing audio or offering a stream to an airplay device is not yet implemented 19 | but is planned to. Do not hesitate to send me any ideas or bug informations. 20 | 21 | ## Installation 22 | 23 | ``` 24 | gem install airstream 25 | ``` 26 | 27 | See **Troubleshooting** below, if you experience any problems while installing. 28 | 29 | ## Basic Usage 30 | 31 | Use the output argument to specify the ip-address of the remote device. Remote 32 | files will be played directly, local files are hosted with a small webserver 33 | (webrick). Check your firewall settings, port 7000 is used to host the file. 34 | 35 | ```shell 36 | airstream http://example.com/sample.mp4 -o 192.168.1.8 37 | airstream /home/me/sample.mp4 -o 192.168.1.8 38 | ``` 39 | You can also use wildcards and handle files as usually... 40 | ```shell 41 | airstream /home/me/*.mp4 -o 192.168.1.8 42 | airstream /home/me/my-birthday-20{08,10,12}.mov -o 192.168.1.8 43 | ``` 44 | Use airimg to send images to the airplay device. 45 | ```shell 46 | airimg http://example.com/photo.png -o 192.168.1.8 47 | airimg ~/Pictures/photo.png 48 | ``` 49 | Use -n option to set the interval between switching to the next picture. 50 | ```shell 51 | airimg ~/Pictures/*.png -n 8 52 | ``` 53 | 54 | See the help for further command line options. To specify default options 55 | use the configuration file initial generated in ~/.airstreamrc. 56 | 57 | ## Controls 58 | 59 | airstream only while playing a video file: 60 | ``` 61 | q ... quit 62 | > ... skip to next file 63 | < ... skip to prev file 64 | . ... +30 seconds 65 | , ... -30 seconds 66 | space ... pause/resume 67 | ``` 68 | 69 | 70 | ## Troubleshooting 71 | 72 | 73 | ### Missing ruby development libraries 74 | 75 | **Error** 76 | 77 | ``` 78 | ERROR: Error installing airstream: 79 | ERROR: Failed to build gem native extension. 80 | 81 | /usr/bin/ruby1.9.1 extconf.rb 82 | /usr/lib/ruby/1.9.1/rubygems/custom_require.rb:36:in `require': cannot load such file -- mkmf (LoadError) 83 | from /usr/lib/ruby/1.9.1/rubygems/custom_require.rb:36:in `require' 84 | from extconf.rb:1:in `
' 85 | ``` 86 | 87 | **Solution** 88 | 89 | Install ruby development packages: 90 | 91 | For Ubuntu/Debian: 92 | 93 | ``` 94 | sudo apt-get install ruby-dev 95 | ``` 96 | 97 | 98 | ### Missing dnssd headers 99 | 100 | **Error** 101 | 102 | ``` 103 | ERROR: Error installing airstream: 104 | ERROR: Failed to build gem native extension. 105 | 106 | /usr/bin/ruby1.9.1 extconf.rb 107 | checking for dns_sd.h... no 108 | unable to find dnssd header 109 | ``` 110 | 111 | **Solution** 112 | 113 | For Ubuntu/Debian: 114 | 115 | ``` 116 | sudo apt-get install libavahi-compat-libdnssd-dev 117 | ``` 118 | 119 | For FreeBSD: 120 | 121 | ``` 122 | sudo pkg install avahi-libdns 123 | gem install airstream -- --with-dnssd-lib=/usr/local/lib/ --with-dnssd-include=/usr/local/include/avahi-compat-libdns_sd/ 124 | ``` 125 | 126 | ## History 127 | 128 | - current 129 | - fix progressbar fails on broken input 130 | - fix some dependencies 131 | - v0.4.0 132 | - Finally added tests 133 | - Improved stability 134 | - Major Bugfixes 135 | - v0.1.0 - v0.3.7 136 | - Added usage examples 137 | - Refactored structure 138 | - More input controls 139 | - Added basic input controls 140 | - Extended Readme 141 | - Dependency fixes 142 | - Changing format of progress bar 143 | - Hosting local file directly using webrick 144 | - Play remote files 145 | - Play local video files with local webserver 146 | - Sending Images 147 | 148 | ## Thanks 149 | 150 | Thanks to Bruno Aguirre (https://github.com/elcuervo) for rubys airplay gem and 151 | Clément Vasseur for the Unofficial AirPlay Protocol Specification 152 | (http://nto.github.com/AirPlay.html). 153 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | require 'cucumber/rake/task' 3 | require 'rake/testtask' 4 | 5 | task :default => [:test, :features] 6 | 7 | spec = eval(File.read('airstream.gemspec')) 8 | Gem::PackageTask.new(spec) { |pkg| } 9 | 10 | Rake::TestTask.new do |t| 11 | t.pattern = "test/*_test.rb" 12 | end 13 | 14 | Cucumber::Rake::Task.new(:features) do |t| 15 | t.cucumber_opts = "features --format pretty" 16 | end 17 | -------------------------------------------------------------------------------- /airstream.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path("../lib", __FILE__) 2 | require 'airstream/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "airstream" 6 | s.version = Airstream::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ["Christoph Lipautz"] 9 | s.email = ["christoph at lipautz.org"] 10 | s.homepage = "https://github.com/unused/airstream" 11 | s.summary = %q{A command line tool for streaming to airplay-devices} 12 | s.description = %q{A command line tool to stream video and image files to 13 | airplay-devices.} 14 | s.files = %w( 15 | bin/airstream 16 | bin/airimg 17 | lib/airstream.rb 18 | lib/airstream/device.rb 19 | lib/airstream/io.rb 20 | lib/airstream/network.rb 21 | lib/airstream/node.rb 22 | lib/airstream/player.rb 23 | lib/airstream/version.rb 24 | lib/airstream/video.rb 25 | ) 26 | s.require_paths << 'lib' 27 | s.executables = ["airstream", "airimg"] 28 | 29 | 30 | s.add_dependency 'airplay', '~> 0.2.9' 31 | s.add_dependency 'ruby-progressbar', '~> 1.1.1' 32 | s.add_dependency 'rack', '~> 2.2.3' 33 | s.add_dependency 'webrick', '~> 1.6.1' 34 | 35 | s.add_development_dependency 'rake', '~> 10.1.0' 36 | end 37 | -------------------------------------------------------------------------------- /bin/airimg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'airstream' 4 | 5 | def self.exit(status) 6 | Airstream::Io.show_input 7 | puts "\n" # clear output 8 | super status 9 | end 10 | 11 | options = { 12 | receiver: '192.168.0.123', 13 | quiet: false, 14 | verbose: false, 15 | # disable_local_http: true 16 | interval: 3 17 | } 18 | 19 | EXIT_OK = 0 20 | EXIT_ERROR = 1 21 | EXIT_NO_HOST = 68 22 | 23 | CONFIG_FILE = File.join(ENV['HOME'] || '', '.airstreamrc') 24 | 25 | if File.exists? CONFIG_FILE 26 | options_config = YAML.load_file(CONFIG_FILE) 27 | options.merge!(options_config) 28 | end 29 | 30 | option_parser = OptionParser.new do |opts| 31 | executable_name = File.basename($PROGRAM_NAME) 32 | opts.banner = "offer a image file to an airplay device 33 | 34 | Usage: #{executable_name} [options] [url|path/]filename 35 | 36 | Basic options: (configure default in ~/.airstreamrc) 37 | " 38 | opts.on("-o RECEIVER", 39 | "the airplay-device ip-address or domain") do |receiver| 40 | options[:receiver] = receiver 41 | end 42 | 43 | opts.on("-n SECONDS", "--interval SECONDS", 44 | "seconds between switching image files") do |interval| 45 | options[:interval] = interval.to_i 46 | end 47 | 48 | opts.on("-v", "--version", 49 | "output version information then quit") do |path| 50 | puts "airstream airimg v" + Airstream::VERSION 51 | exit 0 52 | end 53 | end 54 | 55 | if ARGV.empty? 56 | STDERR.puts "No arguments given" 57 | STDERR.puts option_parser 58 | exit 1 59 | end 60 | 61 | begin 62 | option_parser.parse! 63 | 64 | unless options[:receiver] 65 | STDERR.puts "No host given" 66 | exit 68 67 | end 68 | 69 | node = Airstream::Node.new options[:receiver] 70 | device = Airstream::Device.new node 71 | 72 | io = Airstream::Io.new 73 | io.quiet = options[:quiet] 74 | io.verbose = options[:verbose] 75 | io.puts "=> press ["+Airstream::Io::KEY_QUIT+"] to exit airstream" 76 | Airstream::Io.hide_input 77 | ARGV.each do |file| 78 | device.image = file 79 | sleep options[:interval] 80 | io.catch_input 81 | break if io.quit? 82 | end 83 | 84 | 85 | rescue Interrupt 86 | STDERR.puts 87 | STDERR.puts "exiting" 88 | exit EXIT_OK 89 | rescue OptionParser::InvalidArgument => ex 90 | STDERR.puts ex.message 91 | STDERR.puts option_parser 92 | exit EXIT_ERROR 93 | ensure 94 | Airstream::Io.show_input 95 | end 96 | 97 | exit EXIT_OK 98 | -------------------------------------------------------------------------------- /bin/airstream: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'airstream' 4 | 5 | def self.exit(status) 6 | Airstream::Io.show_input 7 | puts "\n" # clear output 8 | super status 9 | end 10 | 11 | options = { 12 | reciever: '192.168.0.123', 13 | quiet: false, 14 | verbose: false, 15 | # disable_local_http: true 16 | } 17 | 18 | CONFIG_FILE = File.join(ENV['HOME'] || '', '.airstreamrc') 19 | 20 | EXIT_OK = 0 21 | EXIT_ERROR = 1 22 | EXIT_NO_HOST = 68 23 | 24 | UPDATE_TIMEOUT = 0.2 25 | 26 | if File.exists? CONFIG_FILE 27 | options_config = YAML.load_file(CONFIG_FILE) 28 | options.merge!(options_config) 29 | else 30 | File.open(CONFIG_FILE,'w') { |file| YAML::dump(options,file) } 31 | STDERR.puts "Initialized configuration file in #{CONFIG_FILE}" 32 | end 33 | 34 | option_parser = OptionParser.new do |opts| 35 | executable_name = File.basename($PROGRAM_NAME) 36 | opts.banner = "offer a video file to an airplay device 37 | 38 | Usage: #{executable_name} [options] [url|path/]filename 39 | 40 | Basic options: (configure default in ~/.airstreamrc) 41 | " 42 | 43 | opts.on("-o RECIEVER", 44 | "the airplay-device ip-address or domain") do |reciever| 45 | options[:reciever] = reciever 46 | end 47 | 48 | opts.on("-q","--quiet", 49 | "prevent most of the output") do |quiet| 50 | options[:quiet] = quiet 51 | end 52 | 53 | opts.on("--verbose", 54 | "additional output") do |verbose| 55 | options[:verbose] = verbose 56 | end 57 | 58 | # opts.on("--disable-use-httpd", 59 | # "do not create httpd to offer local files") do |disable_httpd| 60 | # options[:disable_local_httpd] = true 61 | # end 62 | 63 | opts.on("-v", "--version", 64 | "output version information then quit") do |path| 65 | puts "airstream v" + Airstream::VERSION 66 | exit EXIT_OK 67 | end 68 | end 69 | 70 | if ARGV.empty? 71 | STDERR.puts "No arguments given" 72 | STDERR.puts option_parser 73 | exit EXIT_ERROR 74 | end 75 | playlist = ARGV 76 | 77 | begin 78 | 79 | option_parser.parse! 80 | 81 | unless options[:reciever] 82 | STDERR.puts "No host given" 83 | exit EXIT_NO_HOST 84 | end 85 | 86 | io = Airstream::Io.new 87 | io.quiet = options[:quiet] 88 | io.verbose = options[:verbose] 89 | node = Airstream::Node.new options[:reciever] 90 | device = Airstream::Device.new node 91 | io.puts "loading can take a few seconds..." 92 | playlist.map! { |file| Airstream::Video.new(file) } 93 | player = Airstream::Player.new device, playlist 94 | 95 | io.puts "=> press ["+Airstream::Io::KEY_QUIT+"] to exit airstream" 96 | Airstream::Io.hide_input 97 | total_duration = player.duration 98 | pbar = ProgressBar.create({ format: '%t |%b%i| %p%%', total: total_duration }) 99 | begin # reconsider playing... 100 | sleep UPDATE_TIMEOUT 101 | formatted_time = Time.at(player.elapsed_time).gmtime.strftime('%R:%S') 102 | pbar.title = "#{player.current_title} #{formatted_time}" 103 | pbar.progress = [player.elapsed_time, total_duration].min 104 | io.catch_input 105 | player.update io 106 | end until io.quit? || player.finished? 107 | pbar.finish 108 | 109 | rescue Airplay::Protocol::InvalidMediaError 110 | STDERR.puts 111 | STDERR.puts "invalid media file" 112 | exit EXIT_ERROR 113 | # rescue NoMethodError # @FIX webrick raises no method error 114 | # STDERR.puts 115 | # STDERR.puts "file host shutdown" 116 | # exit EXIT_OK 117 | rescue Interrupt 118 | STDERR.puts 119 | STDERR.puts "exiting" 120 | exit EXIT_ERROR 121 | rescue OptionParser::InvalidArgument => ex 122 | STDERR.puts ex.message 123 | STDERR.puts option_parser 124 | exit EXIT_ERROR 125 | ensure 126 | Airstream::Io.show_input 127 | end 128 | 129 | exit EXIT_OK 130 | 131 | -------------------------------------------------------------------------------- /examples/imgur-funny-stream: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | puts "airstream example 4 | parses the imgur-funny rss feed and streams images 5 | to an airplay device. 6 | 7 | requires: 8 | gem feedzirra (https://github.com/pauldix/feedzirra) 9 | gem nokogiri (https://github.com/sparklemotion/nokogiri) 10 | 11 | note: this script will never stop ;) 12 | " 13 | 14 | require 'feedzirra' 15 | require 'nokogiri' 16 | 17 | SWITCH_TIMEOUT = 8 18 | 19 | feed = Feedzirra::Feed.fetch_and_parse("http://imgur.com/r/funny/rss") 20 | 21 | loop do 22 | feed.entries.each do |entry| 23 | img = Nokogiri::HTML.parse(entry.summary).css('img').first['src'] 24 | `airimg #{img} -n #{SWITCH_TIMEOUT}` 25 | end 26 | feed = Feedzirra::Feed.update(feed) 27 | end 28 | 29 | -------------------------------------------------------------------------------- /features/error-handling.feature: -------------------------------------------------------------------------------- 1 | Feature: error handling 2 | 3 | In order to get notice of an error 4 | As a user using airstream 5 | I want to know if anything goes wrong 6 | 7 | Scenario: no file given 8 | When I execute airstream 9 | Then the exit status should be 1 10 | And the output should contain "No arguments given" 11 | And I should see the usage message 12 | 13 | -------------------------------------------------------------------------------- /features/step_definitions/general_steps.rb: -------------------------------------------------------------------------------- 1 | 2 | When /(?:[^ ]) execute airstream/ do 3 | step 'I run `bundle exec ruby -Ilib bin/airstream`' 4 | end 5 | 6 | Then /(?:[^ ]) should see the usage message/ do 7 | step 'the output should contain "Usage:"' 8 | end 9 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'aruba/cucumber' 3 | 4 | Before do 5 | @dirs = ["./"] 6 | end 7 | -------------------------------------------------------------------------------- /lib/airstream.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'optparse' 3 | require 'airplay' 4 | require 'ruby-progressbar' 5 | require 'yaml' 6 | require 'rack' 7 | 8 | module Airstream 9 | AIRPLAY_PORT = 7000 10 | AIRSTREAM_PORT = 7000 11 | end 12 | 13 | require 'airstream/version.rb' 14 | require 'airstream/io.rb' 15 | require 'airstream/network.rb' 16 | require 'airstream/node.rb' 17 | require 'airstream/player.rb' 18 | require 'airstream/video.rb' 19 | require 'airstream/device.rb' 20 | -------------------------------------------------------------------------------- /lib/airstream/device.rb: -------------------------------------------------------------------------------- 1 | 2 | module Airstream 3 | 4 | class Device 5 | 6 | attr_reader :player, :video_title 7 | 8 | def initialize(receiver) 9 | @receiver = receiver 10 | end 11 | 12 | def file=(file) 13 | if file.class == Video 14 | self.video = file 15 | # when 16 | # TODO Image then self.image = file 17 | else 18 | raise "Unknown file type send to device" 19 | end 20 | block_while_loading 21 | end 22 | 23 | def block_while_loading 24 | sleep 0.2 until position != 0 25 | end 26 | 27 | def image=(image_file) 28 | @receiver.send_image image_file 29 | end 30 | 31 | def video=(video) 32 | @player = @receiver.send_video video.url 33 | @video_title = video.to_s 34 | end 35 | 36 | def pause 37 | @player.pause 38 | end 39 | 40 | def resume 41 | @player.resume 42 | end 43 | 44 | def scrub(seconds=nil) 45 | @player.scrub seconds 46 | end 47 | 48 | def duration 49 | self.scrub["duration"] 50 | end 51 | 52 | def position 53 | self.scrub["position"] 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/airstream/io.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'io/wait' 3 | 4 | module Airstream 5 | class Io 6 | KEY_QUIT = 'q' 7 | KEY_SKIP = '>' 8 | KEY_PREV = '<' 9 | KEY_FWD = '.' 10 | KEY_BACK = ',' 11 | KEY_PAUSE = ' ' 12 | 13 | attr_writer :quiet, :verbose 14 | attr_reader :key 15 | 16 | def print(msg) 17 | STDOUT.print msg unless @quiet 18 | end 19 | 20 | def puts(msg) 21 | STDOUT.puts msg unless @quiet 22 | end 23 | 24 | def info(msg) 25 | STDOUT.puts msg if @verbose 26 | end 27 | 28 | def error(msg) 29 | STDERR.puts msg 30 | end 31 | 32 | def self.hide_input 33 | `stty raw -echo` 34 | end 35 | 36 | def self.show_input 37 | `stty -raw echo` 38 | end 39 | 40 | def catch_input 41 | @key = nil 42 | if STDIN.ready? 43 | @key = STDIN.getc 44 | end 45 | rescue 46 | show_input 47 | end 48 | 49 | def quit? 50 | @key == KEY_QUIT 51 | end 52 | 53 | def skip? 54 | @key == KEY_SKIP 55 | end 56 | 57 | def prev? 58 | @key == KEY_PREV 59 | end 60 | 61 | def fwd? 62 | @key == KEY_FWD 63 | end 64 | 65 | def back? 66 | @key == KEY_BACK 67 | end 68 | 69 | def pause? 70 | @key == KEY_PAUSE 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/airstream/network.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'socket' 3 | 4 | module Airstream 5 | class Network 6 | # http://stackoverflow.com/questions/5029427/ruby-get-local-ip-nix 7 | def self.get_local_ip 8 | Socket.ip_address_list.detect{|intf| intf.ipv4_private?}.ip_address 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/airstream/node.rb: -------------------------------------------------------------------------------- 1 | 2 | module Airstream 3 | class Node < Airplay::Client 4 | 5 | def initialize(receiver) 6 | node = Airplay::Server::Node.new( 7 | receiver, receiver, receiver, AIRPLAY_PORT) 8 | super node 9 | # TODO test if does not work without 10 | use node # does not work without that second assign 11 | end 12 | 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/airstream/player.rb: -------------------------------------------------------------------------------- 1 | module Airstream 2 | class Player 3 | 4 | SEEK_SECONDS = 30 5 | 6 | def initialize(device, files) 7 | @device, @files = device, files 8 | unless @files.empty? 9 | play 10 | else 11 | @finished = true 12 | end 13 | end 14 | 15 | def play(index=0) 16 | unless @files.fetch(index, false) 17 | err_msg = "no file found at index #{index}" 18 | raise IndexError, err_msg 19 | end 20 | @file_index = index 21 | self.current_file = @files[@file_index] 22 | end 23 | 24 | def current_file=(file) 25 | @device.file = file 26 | end 27 | private :current_file= 28 | 29 | # TODO register commands instead of switch known 30 | def update(io) 31 | if io.quit? 32 | Airstream::Io.show_input ; @finished = true 33 | elsif io.skip? || duration <= elapsed_time 34 | self.next 35 | elsif io.prev? 36 | self.prev 37 | elsif io.fwd? 38 | self.seek(elapsed_time + SEEK_SECONDS) 39 | elsif io.back? 40 | self.seek(elapsed_time - SEEK_SECONDS) 41 | elsif io.pause? 42 | @pause ||= false 43 | (@pause = !@pause) ? @device.pause : @device.resume 44 | end 45 | end 46 | 47 | def seek(seconds) 48 | if seconds.between? 0, duration 49 | @device.scrub(seconds) 50 | end 51 | end 52 | 53 | def next 54 | @file_index += 1 55 | if @file_index < @files.size 56 | self.current_file = @files[@file_index] 57 | else 58 | @finished = true 59 | end 60 | end 61 | 62 | def prev 63 | if @file_index == 0 64 | self.seek(0) 65 | elsif @files.size > @file_index 66 | @file_index -= 1 67 | self.current_file = @files[@file_index] 68 | end 69 | end 70 | 71 | def current_title 72 | @files[@file_index] 73 | end 74 | 75 | def finished? 76 | @finished || false 77 | end 78 | 79 | def loading? 80 | elapsed_time == 0 81 | end 82 | 83 | def elapsed_time 84 | @device.position 85 | end 86 | 87 | def duration 88 | @device.duration 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/airstream/version.rb: -------------------------------------------------------------------------------- 1 | 2 | module Airstream 3 | VERSION = '0.4.10' 4 | end 5 | -------------------------------------------------------------------------------- /lib/airstream/video.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'rack' 3 | require 'webrick' 4 | 5 | module Airstream 6 | class Video 7 | 8 | @@server = nil 9 | 10 | def initialize(video_file) 11 | @filename = video_file 12 | end 13 | 14 | def to_s 15 | File.basename(@filename, File.extname(@filename)) 16 | end 17 | 18 | def url 19 | File.exists?(@filename) ? host_file : @filename 20 | end 21 | 22 | def host_file 23 | @@server.server.shutdown if @@server 24 | @@server = Rack::Server.new( 25 | :server => :webrick, 26 | :Host => Airstream::Network.get_local_ip, 27 | :Port => AIRSTREAM_PORT, 28 | :app => Rack::File.new(@filename), 29 | :AccessLog => [], # stfu webrick 30 | :Logger => WEBrick::Log::new("/dev/null", 7) 31 | ) 32 | Thread.start do 33 | @@server.start 34 | end 35 | "http://#{@@server.options[:Host]}:#{@@server.options[:Port]}" 36 | end 37 | private :host_file 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/device_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('helper/bootstrap', File.dirname(__FILE__)) 2 | 3 | describe Airstream::Device do 4 | 5 | describe "when a video file is set" do 6 | it "a title should be read from filename" do 7 | receiver = MiniTest::Mock.new 8 | receiver.expect :send_video, true, ["http://example.com/my-birthday-2010.mp4"] 9 | device = Airstream::Device.new receiver 10 | video = MiniTest::Mock.new 11 | video.expect :url, "http://example.com/my-birthday-2010.mp4" 12 | video.expect :to_s, "my-birthday-2010" 13 | device.video = video 14 | device.video_title.must_equal "my-birthday-2010" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/helper/bootstrap.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | require File.expand_path('../../lib/airstream.rb', File.dirname(__FILE__)) 5 | 6 | require 'minitest' 7 | require 'minitest/spec' 8 | require 'minitest/autorun' 9 | require 'minitest/pride' 10 | # require 'pry-rescue/minitest' 11 | -------------------------------------------------------------------------------- /test/network_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('helper/bootstrap', File.dirname(__FILE__)) 2 | 3 | class AirstreamNetwork < MiniTest::Test 4 | def test_getting_local_ip 5 | ip = Airstream::Network.get_local_ip 6 | `ifconfig`.must_match /#{ip}/ 7 | end 8 | end 9 | 10 | -------------------------------------------------------------------------------- /test/player_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('helper/bootstrap', File.dirname(__FILE__)) 2 | 3 | describe Airstream::Player do 4 | before do 5 | @device = MiniTest::Mock.new 6 | end 7 | 8 | describe "when no files are given" do 9 | it "should be finished" do 10 | player = Airstream::Player.new(@device, []) 11 | assert player.finished? 12 | end 13 | end 14 | 15 | describe "when play a file-position that does not exist" do 16 | it "should raise an IndexError" do 17 | player = Airstream::Player.new(@device, []) 18 | assert_raises IndexError do 19 | player.play(666) 20 | end 21 | end 22 | end 23 | 24 | describe "when video files are given" do 25 | it "should start playing" do 26 | files = [Airstream::Video.new("http://example.com/video.mp4")] 27 | @device.expect(:file=, true, [files[0]]) 28 | player = Airstream::Player.new(@device, files) 29 | @device.verify 30 | end 31 | end 32 | 33 | describe "when next is called and files are given" do 34 | it "should next to next file" do 35 | files = [ 36 | Airstream::Video.new("http://example.com/first.mp4"), 37 | Airstream::Video.new("http://example.com/second.mp4") 38 | ] 39 | @device.expect(:file=, true, [files[0]]) 40 | @device.expect(:file=, true, [files[1]]) 41 | player = Airstream::Player.new(@device, files) 42 | player.next 43 | @device.verify 44 | end 45 | end 46 | 47 | describe "when next is called and last file is given" do 48 | it "should be finished" do 49 | files = [Airstream::Video.new("http://example.com/first.mp4")] 50 | @device.expect(:file=, true, [files[0]]) 51 | player = Airstream::Player.new(@device, files) 52 | player.next 53 | @device.verify ; player.finished?.must_equal true 54 | end 55 | end 56 | 57 | describe "when prev is called" do 58 | it "should be switch back to previous file" do 59 | files = [ 60 | Airstream::Video.new("http://example.com/first.mp4"), 61 | Airstream::Video.new("http://example.com/second.mp4") 62 | ] 63 | @device.expect(:file=, true, [files[0]]) 64 | @device.expect(:file=, true, [files[1]]) 65 | @device.expect(:file=, true, [files[0]]) 66 | player = Airstream::Player.new(@device, files) 67 | player.next 68 | player.prev 69 | @device.verify ; player.finished?.must_equal false 70 | end 71 | end 72 | 73 | describe "when prev is called on first file" do 74 | it "should start from beginning" do 75 | files = [Airstream::Video.new("http://example.com/first.mp4")] 76 | @device.expect(:file=, true, [files[0]]) 77 | @device.expect(:duration, 25) 78 | @device.expect(:scrub, true, [0]) 79 | player = Airstream::Player.new(@device, files) 80 | player.prev 81 | @device.verify 82 | end 83 | end 84 | 85 | describe "when a file is file= to the device" do 86 | it "the player should be loading" do 87 | files = [Airstream::Video.new("http://example.com/first.mp4")] 88 | @device.expect(:file=, true, [files[0]]) 89 | @device.expect(:position, 0) 90 | @device.expect(:position, 1) 91 | @device.expect(:position, 45) 92 | player = Airstream::Player.new(@device, files) 93 | player.loading?.must_equal true 94 | player.loading?.must_equal false 95 | player.loading?.must_equal false 96 | @device.verify 97 | end 98 | end 99 | 100 | # OPTIMIZE find better way to handle IO Mock 101 | describe "when the player is paused" do 102 | it "should either file= pause or resume" do 103 | player = Airstream::Player.new(@device, []) 104 | io = MiniTest::Mock.new 105 | 5.times do 106 | [:quit?, :skip?, :prev?, :fwd?, :back?].each do |cmd| 107 | io.expect(cmd, false) 108 | end 109 | @device.expect(:duration, 90) 110 | @device.expect(:position, 1) 111 | io.expect(:pause?, true) 112 | @device.expect(:pause, true) 113 | player.update io 114 | [:quit?, :skip?, :prev?, :fwd?, :back?].each do |cmd| 115 | io.expect(cmd, false) 116 | end 117 | io.expect(:pause?, true) 118 | @device.expect(:duration, 90) 119 | @device.expect(:position, 1) 120 | @device.expect(:resume, true) 121 | player.update io 122 | end 123 | @device.verify 124 | io.verify 125 | end 126 | end 127 | end 128 | --------------------------------------------------------------------------------