├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── soundcloud2000 ├── lib ├── soundcloud2000.rb └── soundcloud2000 │ ├── application.rb │ ├── client.rb │ ├── controllers │ ├── controller.rb │ ├── player_controller.rb │ └── track_controller.rb │ ├── download_thread.rb │ ├── events.rb │ ├── models │ ├── collection.rb │ ├── player.rb │ ├── playlist.rb │ ├── track.rb │ ├── track_collection.rb │ └── user.rb │ ├── time_helper.rb │ ├── ui │ ├── canvas.rb │ ├── color.rb │ ├── input.rb │ ├── rect.rb │ ├── table.rb │ └── view.rb │ └── views │ ├── player_view.rb │ ├── splash.rb │ └── tracks_table.rb ├── soundcloud2000 ├── soundcloud2000.gemspec └── spec ├── controllers └── track_controller_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *~ 3 | *.log 4 | Gemfile.lock 5 | pkg/ 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in audite.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Matthias Georgi and Tobias Schmidt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # soundcloud2000 2 | 3 | The next generation SoundCloud client. Without all these stupid CSS files. Runs on OSX and Linux. 4 | 5 | ![Screen Shot 2013-01-20 at 15 37 03](https://f.cloud.github.com/assets/3432/81282/06c44c7e-630f-11e2-9a91-85c9b917835c.png) 6 | ![Screen Shot 2013-01-20 at 15 37 54](https://f.cloud.github.com/assets/3432/81281/06b05df4-630f-11e2-8b55-7f3c18126831.png) 7 | 8 | This hack was built at the [Music Hack Day Stockholm 2013](http://stockholm.musichackday.org/2013). 9 | 10 | ## Requirements 11 | 12 | * Ruby (1.9) 13 | * Portaudio (19) 14 | * Mpg123 (1.14) 15 | 16 | ## Installation 17 | 18 | Assuming you have Ruby/Rubygems installed, you need portaudio and mpg123 as 19 | library to compile the native extensions. 20 | 21 | ### OSX 22 | 23 | xcode-select --install 24 | brew install portaudio 25 | brew install mpg123 26 | gem install soundcloud2000 27 | 28 | ### Debian / Ubuntu 29 | 30 | apt-get install portaudio19-dev libmpg123-dev libncurses-dev ruby1.9.1-dev 31 | gem install soundcloud2000 32 | 33 | ## Usage 34 | 35 | In order to use soundcloud2000, you need to [acquire a client credential for your application](http://soundcloud.com/you/apps/new). soundcloud2000 expects a valid client id to be set in the SC_CLIENT_ID environment variable. 36 | 37 | You can either set this up in your `.bashrc` or equivalent or you can specify it on the command line: 38 | 39 | SC_CLIENT_ID=YOUR_CLIENT_ID soundcloud2000 40 | 41 | ## Features 42 | 43 | * stream SoundCloud tracks in your terminal (`enter`) 44 | * scroll through sound lists (`down` / `up`) 45 | * play / pause support (`space`) 46 | * forward / rewind support (`right` / `left`) 47 | * play tracks of different users (`u`) 48 | * play favorites from a user (`f`) 49 | * play sets/playlists from a user (`s`) 50 | * level meter 51 | 52 | ## Planned 53 | 54 | * play any streams, sets or sounds 55 | * better browsing between users and sound lists 56 | 57 | ## Authors 58 | 59 | * [Matthias Georgi](https://github.com/georgi) ([@mgeorgi](https://twitter.com/mgeorgi)) 60 | * [Tobias Schmidt](https://github.com/grobie) ([@dagrobie](https://twitter.com/dagrobie)) 61 | 62 | ## Contributors 63 | 64 | * [Travis Thieman](https://github.com/tthieman) ([@tthieman](https://twitter.com/thieman)) 65 | * [Sean Lewis](https://github.com/sophisticasean) ([@FricSean](https://twitter.com/fricsean)) 66 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake' 3 | require 'yaml' 4 | 5 | def dependencies(file) 6 | `otool -L #{file}`.split("\n")[1..-1].map {|line| line.split[0] } 7 | end 8 | 9 | task :dirs do 10 | Dir.mkdir 'vendor' rescue nil 11 | Dir.mkdir 'vendor/bin' rescue nil 12 | Dir.mkdir 'vendor/lib' rescue nil 13 | Dir.mkdir 'vendor/dyld' rescue nil 14 | end 15 | 16 | task :ruby => :dirs do 17 | lines = `rvm info`.split("\n")[6..-1] 18 | ruby = YAML.load(lines.join("\n")) 19 | 20 | version = ruby.keys.first 21 | home = ruby[version]['homes']['ruby'] 22 | bin = ruby[version]['binaries']['ruby'] 23 | # dylib = dependencies(bin)[0] 24 | 25 | `cp -a #{home}/lib/ruby/1.9.1/* vendor/lib` 26 | `cp #{bin} vendor/` 27 | # `cp #{dylib} vendor/dyld` 28 | end 29 | 30 | task :libs => :dirs do 31 | [:audite, :portaudio, :mpg123].each do |name| 32 | lib = `gem which #{name}`.chomp 33 | `cp #{lib} vendor/lib` 34 | if File.extname(lib) == '.bundle' 35 | dylib = dependencies(lib)[0] 36 | `cp #{dylib} vendor/dyld` 37 | end 38 | end 39 | end 40 | 41 | task :package => [:ruby, :libs] do 42 | `cp soundcloud2000 vendor` 43 | `cp -a bin/* vendor/bin` 44 | `cp -a lib/* vendor/lib` 45 | `tar czf soundcloud2000.tgz vendor` 46 | end 47 | -------------------------------------------------------------------------------- /bin/soundcloud2000: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # use local gem if present 4 | lib = File.expand_path('../lib', File.dirname(__FILE__)) 5 | $LOAD_PATH.unshift(lib) if File.directory?(lib) 6 | 7 | require 'soundcloud2000' 8 | 9 | Soundcloud2000.start 10 | -------------------------------------------------------------------------------- /lib/soundcloud2000.rb: -------------------------------------------------------------------------------- 1 | require_relative 'soundcloud2000/client' 2 | require_relative 'soundcloud2000/application' 3 | 4 | module Soundcloud2000 5 | 6 | def self.start 7 | unless client_id = ENV['SC_CLIENT_ID'] 8 | puts "You need to set SC_CLIENT_ID to a valid client ID" 9 | exit 1 10 | end 11 | 12 | client = Client.new(client_id) 13 | application = Application.new(client) 14 | 15 | Signal.trap('SIGINT') do 16 | application.stop 17 | end 18 | 19 | application.run 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/soundcloud2000/application.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | require_relative 'ui/canvas' 4 | require_relative 'ui/input' 5 | require_relative 'ui/rect' 6 | 7 | require_relative 'controllers/track_controller' 8 | require_relative 'controllers/player_controller' 9 | 10 | require_relative 'models/track_collection' 11 | require_relative 'models/player' 12 | 13 | require_relative 'views/tracks_table' 14 | require_relative 'views/splash' 15 | 16 | module Soundcloud2000 17 | class Application 18 | include Controllers 19 | include Models 20 | include Views 21 | 22 | def initialize(client) 23 | $stderr.reopen('debug.log', 'w') 24 | @canvas = UI::Canvas.new 25 | 26 | @splash_controller = Controller.new( 27 | Splash.new( 28 | UI::Rect.new(0, 0, Curses.cols, Curses.lines))) 29 | 30 | @player_controller = PlayerController.new( 31 | PlayerView.new( 32 | UI::Rect.new(0, 0, Curses.cols, 5)), client) 33 | 34 | @track_controller = TrackController.new( 35 | TracksTable.new( 36 | UI::Rect.new(0, 5, Curses.cols, Curses.lines - 5)), client) 37 | 38 | @track_controller.bind_to(TrackCollection.new(client)) 39 | 40 | @track_controller.events.on(:select) do |track| 41 | @player_controller.play(track) 42 | end 43 | 44 | @player_controller.events.on(:complete) do 45 | @track_controller.next_track 46 | end 47 | end 48 | 49 | def main 50 | loop do 51 | if @workaround_was_called_once_already 52 | handle UI::Input.get(-1) 53 | else 54 | @workaround_was_called_once_already = true 55 | handle UI::Input.get(0) 56 | @track_controller.load 57 | @track_controller.render 58 | end 59 | 60 | break if stop? 61 | end 62 | ensure 63 | @canvas.close 64 | end 65 | 66 | def run 67 | @splash_controller.render 68 | main 69 | end 70 | 71 | # TODO: look at active controller and send key to active controller instead 72 | def handle(key) 73 | case key 74 | when :left, :right, :space, :one, :two, :three, :four, :five, :six, :seven, :eight, :nine 75 | @player_controller.events.trigger(:key, key) 76 | when :down, :up, :enter, :u, :f, :s, :j, :k 77 | @track_controller.events.trigger(:key, key) 78 | end 79 | end 80 | 81 | def stop 82 | @stop = true 83 | end 84 | 85 | def stop? 86 | @stop == true 87 | end 88 | 89 | def self.logger 90 | @logger ||= Logger.new('debug.log') 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/soundcloud2000/client.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'json' 3 | 4 | module Soundcloud2000 5 | # responsible for the very basic information of the app 6 | class Client 7 | DEFAULT_LIMIT = 50 8 | 9 | attr_reader :client_id, :current_user 10 | attr_writer :current_user 11 | 12 | def initialize(client_id) 13 | @client_id = client_id 14 | @current_user = nil 15 | end 16 | 17 | def tracks(page = 1, limit = DEFAULT_LIMIT) 18 | get('/tracks', offset: (page - 1) * limit, limit: limit) 19 | end 20 | 21 | def resolve(permalink) 22 | res = get('/resolve', url: "http://soundcloud.com/#{permalink}") 23 | if res['location'] 24 | get URI.parse(res['location']).path 25 | end 26 | end 27 | 28 | def uri_escape(params) 29 | URI.escape(params.collect { |k, v| "#{k}=#{v}" }.join('&')) 30 | end 31 | 32 | def request(type, path, params = {}) 33 | params[:client_id] = client_id 34 | params[:format] = 'json' 35 | 36 | Net::HTTP.start('api.soundcloud.com', 443, use_ssl: true) do |http| 37 | http.request(type.new("#{path}?#{uri_escape params}")) 38 | end 39 | end 40 | 41 | def get(path, params = {}) 42 | JSON.parse(request(Net::HTTP::Get, path, params).body) 43 | end 44 | 45 | def location(url) 46 | uri = URI.parse(url) 47 | res = request(Net::HTTP::Get, uri.path) 48 | res.header['Location'] if res.code == '302' 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/soundcloud2000/controllers/controller.rb: -------------------------------------------------------------------------------- 1 | require_relative '../events' 2 | 3 | module Soundcloud2000 4 | module Controllers 5 | # Control our view, events, and rendering. 6 | class Controller 7 | attr_reader :events 8 | 9 | def initialize(view) 10 | @view = view 11 | @events = Events.new 12 | end 13 | 14 | def render 15 | @view.render 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/soundcloud2000/controllers/player_controller.rb: -------------------------------------------------------------------------------- 1 | require_relative 'controller' 2 | require_relative '../models/player' 3 | require_relative '../views/player_view' 4 | 5 | module Soundcloud2000 6 | module Controllers 7 | # The top section player controller 8 | # Displays current track position 9 | # Equalizer and track information 10 | class PlayerController < Controller 11 | def initialize(view, client) 12 | super(view) 13 | 14 | @client = client 15 | @player = Models::Player.new 16 | 17 | @player.events.on(:progress) do 18 | @view.render 19 | end 20 | 21 | @player.events.on(:complete) do 22 | events.trigger(:complete) 23 | end 24 | 25 | @view.player = @player 26 | 27 | events.on(:key) do |key| 28 | if @player.playing? 29 | case key 30 | when :left 31 | @player.rewind 32 | when :right 33 | @player.forward 34 | when :one 35 | @player.seek_position(1) 36 | when :two 37 | @player.seek_position(2) 38 | when :three 39 | @player.seek_position(3) 40 | when :four 41 | @player.seek_position(4) 42 | when :five 43 | @player.seek_position(5) 44 | when :six 45 | @player.seek_position(6) 46 | when :seven 47 | @player.seek_position(7) 48 | when :eight 49 | @player.seek_position(8) 50 | when :nine 51 | @player.seek_position(9) 52 | end 53 | end 54 | if key == :space 55 | if @player.track 56 | @player.toggle 57 | @view.render 58 | end 59 | end 60 | end 61 | end 62 | 63 | def play(track) 64 | if track.nil? 65 | UI::Input.error('No track currently selected. Use f to switch to '\ 66 | "#{@client.current_user.username}'s favorites, or"\ 67 | ' s to switch to their playlists/sets.') 68 | else 69 | location = @client.location(track.stream_url) 70 | @player.play(track, location) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/soundcloud2000/controllers/track_controller.rb: -------------------------------------------------------------------------------- 1 | require_relative 'controller' 2 | require_relative '../time_helper' 3 | require_relative '../ui/table' 4 | require_relative '../ui/input' 5 | require_relative '../models/track_collection' 6 | require_relative '../models/user' 7 | 8 | module Soundcloud2000 9 | module Controllers 10 | # Handles the navigation thru the current track list 11 | class TrackController < Controller 12 | def initialize(view, client) 13 | super(view) 14 | 15 | @client = client 16 | 17 | events.on(:key) do |key| 18 | case key 19 | when :enter 20 | @view.select 21 | events.trigger(:select, current_track) 22 | when :up, :k 23 | @view.up 24 | when :down, :j 25 | @view.down 26 | @tracks.load_more if @view.bottom? 27 | when :u 28 | user = fetch_user_with_message('Change to soundcloud user: ') 29 | unless user.nil? 30 | @client.current_user = user 31 | @tracks.collection_to_load = :user 32 | @tracks.clear_and_replace 33 | end 34 | when :f 35 | @client.current_user = fetch_user_with_message('Change to SoundCloud user\'s favourites: ') if @client.current_user.nil? 36 | unless @client.current_user.nil? 37 | @tracks.collection_to_load = :favorites 38 | @tracks.clear_and_replace 39 | end 40 | when :s 41 | @view.clear 42 | @client.current_user = fetch_user_with_message('Change to SoundCloud user: ') if @client.current_user.nil? 43 | unless @client.current_user.nil? 44 | set = UI::Input.getstr('Change to SoundCloud playlist: ') 45 | set_request = @client.resolve(@client.current_user.permalink + '/sets/' + set) 46 | if set_request.nil? 47 | UI::Input.error("No such set/playlist '#{set}' for #{@client.current_user.username}") 48 | @client.current_user = nil 49 | else 50 | @tracks.playlist = Models::Playlist.new(set_request) 51 | @tracks.collection_to_load = :playlist 52 | @tracks.clear_and_replace 53 | end 54 | end 55 | end 56 | end 57 | end 58 | 59 | def fetch_user_with_message(message_to_display) 60 | permalink = UI::Input.getstr(message_to_display) 61 | user_hash = @client.resolve(permalink) 62 | if user_hash 63 | Models::User.new(user_hash) 64 | else 65 | UI::Input.error("No such user '#{permalink}'. Use u to try again.") 66 | nil 67 | end 68 | end 69 | 70 | def current_track 71 | @tracks[@view.current] 72 | end 73 | 74 | def bind_to(tracks) 75 | @tracks = tracks 76 | @view.bind_to(tracks) 77 | end 78 | 79 | def load 80 | @tracks.load 81 | end 82 | 83 | def next_track 84 | @view.down 85 | @view.select 86 | events.trigger(:select, current_track) 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/soundcloud2000/download_thread.rb: -------------------------------------------------------------------------------- 1 | require 'net/https' 2 | require_relative 'events' 3 | 4 | module Soundcloud2000 5 | class DownloadThread 6 | attr_reader :events, :url, :progress, :total, :file 7 | 8 | def initialize(url, filename) 9 | @events = Events.new 10 | @url = URI.parse(url) 11 | @file = File.open(filename, "w") 12 | @progress = 0 13 | start! 14 | end 15 | 16 | def log(s) 17 | Soundcloud2000::Application.logger.debug("DownloadThread #{s}") 18 | end 19 | 20 | def start! 21 | Thread.start do 22 | begin 23 | log :start 24 | 25 | http = Net::HTTP.new(url.host, url.port) 26 | http.use_ssl = true 27 | 28 | http.request(Net::HTTP::Get.new(url.request_uri)) do |res| 29 | log "response: #{res.code}" 30 | raise res.body if res.code != '200' 31 | 32 | @total = res.header['Content-Length'].to_i 33 | 34 | res.read_body do |chunk| 35 | @progress += chunk.size 36 | @file << chunk 37 | @file.close if @progress == @total 38 | end 39 | end 40 | rescue => e 41 | log e.message 42 | end 43 | end 44 | 45 | sleep 0.1 while @total.nil? 46 | sleep 0.1 47 | 48 | self 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/soundcloud2000/events.rb: -------------------------------------------------------------------------------- 1 | module Soundcloud2000 2 | class Events 3 | def initialize 4 | @handlers = Hash.new { |h, k| h[k] = [] } 5 | end 6 | 7 | def on(event, &block) 8 | @handlers[event] << block 9 | end 10 | 11 | def trigger(event, *args) 12 | @handlers[event].each do |handler| 13 | handler.call(*args) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/collection.rb: -------------------------------------------------------------------------------- 1 | require_relative '../events' 2 | 3 | module Soundcloud2000 4 | module Models 5 | # stores the tracks displayed in the track controller 6 | class Collection 7 | include Enumerable 8 | attr_reader :events, :rows, :page 9 | 10 | def initialize(client) 11 | @client = client 12 | @events = Events.new 13 | clear 14 | end 15 | 16 | def [](*args) 17 | @rows[*args] 18 | end 19 | 20 | def clear 21 | @page = 0 22 | @rows = [] 23 | @loaded = false 24 | end 25 | 26 | def each(&block) 27 | @rows.each(&block) 28 | end 29 | 30 | def replace(rows) 31 | clear 32 | @rows = rows 33 | events.trigger(:replace) 34 | end 35 | 36 | def append(rows) 37 | @rows += rows 38 | events.trigger(:append) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/player.rb: -------------------------------------------------------------------------------- 1 | require 'audite' 2 | require_relative '../download_thread' 3 | 4 | module Soundcloud2000 5 | module Models 6 | # responsible for drawing and updating the player above tracklist 7 | class Player 8 | attr_reader :track, :events 9 | 10 | def initialize 11 | @track = nil 12 | @events = Events.new 13 | @folder = File.expand_path('~/.soundcloud2000') 14 | @seek_speed = {} 15 | @seek_time = {} 16 | create_player 17 | 18 | Dir.mkdir(@folder) unless File.exist?(@folder) 19 | end 20 | 21 | def create_player 22 | @player = Audite.new 23 | @player.events.on(:position_change) do 24 | events.trigger(:progress) 25 | end 26 | 27 | @player.events.on(:complete) do 28 | events.trigger(:complete) 29 | end 30 | end 31 | 32 | def play(track, location) 33 | log :play, track.id 34 | @track = track 35 | load(track, location) 36 | start 37 | end 38 | 39 | def play_progress 40 | seconds_played / duration 41 | end 42 | 43 | def duration 44 | @track.duration.to_f / 1000 45 | end 46 | 47 | def title 48 | [@track.title, @track.user.username].join(' - ') 49 | end 50 | 51 | def length_in_seconds 52 | mpg = Mpg123.new(@file) 53 | mpg.length * mpg.tpf / mpg.spf 54 | end 55 | 56 | def load(track, location) 57 | @file = "#{@folder}/#{track.id}.mp3" 58 | 59 | if !File.exist?(@file) || track.duration / 1000 > length_in_seconds * 0.95 60 | File.unlink(@file) rescue nil 61 | @download = DownloadThread.new(location, @file) 62 | else 63 | @download = nil 64 | end 65 | 66 | @player.load(@file) 67 | end 68 | 69 | def log(*args) 70 | Soundcloud2000::Application.logger.debug 'Player: ' + args.join(' ') 71 | end 72 | 73 | def level 74 | @player.level 75 | end 76 | 77 | def seconds_played 78 | @player.position 79 | end 80 | 81 | def download_progress 82 | @download ? @download.progress / @download.total.to_f : 1 83 | end 84 | 85 | def playing? 86 | @player.active 87 | end 88 | 89 | def seek_speed(direction) 90 | if @seek_time[direction] && Time.now - @seek_time[direction] < 0.5 91 | @seek_speed[direction] *= 1.05 92 | else 93 | @seek_speed[direction] = 1 94 | end 95 | 96 | @seek_time[direction] = Time.now 97 | @seek_speed[direction] 98 | end 99 | 100 | # change song position 101 | def seek_position(position) 102 | position *= 0.1 103 | relative_position = position * duration 104 | if relative_position < seconds_played 105 | difference = seconds_played - relative_position 106 | @player.rewind(difference) 107 | elsif download_progress > (relative_position / duration) && relative_position > seconds_played 108 | log download_progress 109 | difference = relative_position - seconds_played 110 | @player.forward(difference) 111 | end 112 | end 113 | 114 | def rewind 115 | @player.rewind(seek_speed(:rewind)) 116 | end 117 | 118 | def forward 119 | seconds = seek_speed(:forward) 120 | 121 | seek_percentage = (seconds + seconds_played) / duration 122 | @player.forward(seconds) if seek_percentage < download_progress 123 | end 124 | 125 | def stop 126 | @player.stop_stream 127 | end 128 | 129 | def start 130 | @player.start_stream 131 | end 132 | 133 | def toggle 134 | @player.toggle 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/playlist.rb: -------------------------------------------------------------------------------- 1 | module Soundcloud2000 2 | module Models 3 | # stores information on a playlist or set from soundcloud 4 | class Playlist 5 | def initialize(hash) 6 | @hash = hash 7 | end 8 | 9 | def id 10 | @hash['id'] 11 | end 12 | 13 | def title 14 | @hash['title'] 15 | end 16 | 17 | def uri 18 | @hash['uri'] 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/track.rb: -------------------------------------------------------------------------------- 1 | require_relative 'user' 2 | 3 | module Soundcloud2000 4 | module Models 5 | # stores information for each track that hits the player 6 | class Track 7 | def initialize(hash) 8 | @hash = hash 9 | end 10 | 11 | def id 12 | @hash['id'] 13 | end 14 | 15 | def title 16 | @hash['title'] 17 | end 18 | 19 | def url 20 | @hash['permalink_url'] 21 | end 22 | 23 | def user 24 | @user ||= User.new(@hash['user']) 25 | end 26 | 27 | def username 28 | user.username 29 | end 30 | 31 | def duration 32 | @hash['duration'] 33 | end 34 | 35 | def length 36 | TimeHelper.duration(duration) 37 | end 38 | 39 | def plays 40 | @hash['playback_count'] 41 | end 42 | 43 | def likes 44 | @hash['favoritings_count'] 45 | end 46 | 47 | def comments 48 | @hash['comments'] 49 | end 50 | 51 | def stream_url 52 | @hash['stream_url'] 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/track_collection.rb: -------------------------------------------------------------------------------- 1 | require_relative 'collection' 2 | require_relative 'track' 3 | require_relative 'playlist' 4 | 5 | module Soundcloud2000 6 | module Models 7 | # This model deals with the different types of tracklists that populate 8 | # the tracklist section 9 | class TrackCollection < Collection 10 | DEFAULT_LIMIT = 50 11 | 12 | attr_reader :limit 13 | attr_accessor :collection_to_load, :user, :playlist 14 | 15 | def initialize(client) 16 | super 17 | @limit = DEFAULT_LIMIT 18 | @collection_to_load = :recent 19 | end 20 | 21 | def size 22 | @rows.size 23 | end 24 | 25 | def clear_and_replace 26 | clear 27 | load_more 28 | events.trigger(:replace) 29 | end 30 | 31 | def load 32 | clear 33 | load_more 34 | end 35 | 36 | def load_more 37 | unless @loaded 38 | tracks = send(@collection_to_load.to_s + '_tracks') 39 | @loaded = true if tracks.empty? 40 | append tracks.map { |hash| Track.new hash } 41 | @page += 1 42 | end 43 | end 44 | 45 | def favorites_tracks 46 | return [] if @client.current_user.nil? 47 | @client.get(@client.current_user.uri + '/favorites', offset: @limit * @page, limit: @limit) 48 | end 49 | 50 | def recent_tracks 51 | @client.get('/tracks', offset: @page * limit, limit: @limit) 52 | end 53 | 54 | def user_tracks 55 | return [] if @client.current_user.nil? 56 | user_tracks = @client.get(@client.current_user.uri + '/tracks', offset: @limit * @page, limit: @limit) 57 | if user_tracks.empty? 58 | UI::Input.error("'#{@client.current_user.username}' has not authored any tracks. Use f to switch to their favorites, or s to switch to their playlists.") 59 | return [] 60 | else 61 | return user_tracks 62 | end 63 | end 64 | 65 | def playlist_tracks 66 | return [] if @playlist.nil? 67 | @client.get(@playlist.uri + '/tracks', offset: @limit * @page, limit: @limit) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/soundcloud2000/models/user.rb: -------------------------------------------------------------------------------- 1 | module Soundcloud2000 2 | module Models 3 | # stores information on the current user we are looking at 4 | class User 5 | def initialize(hash) 6 | @hash = hash 7 | end 8 | 9 | def id 10 | @hash['id'] 11 | end 12 | 13 | def username 14 | @hash['username'] 15 | end 16 | 17 | def uri 18 | @hash['uri'] 19 | end 20 | 21 | def permalink 22 | @hash['permalink'] 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/soundcloud2000/time_helper.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | 3 | module Soundcloud2000 4 | # handles proper time display 5 | # TODO: make this better looking or find an alternative 6 | module TimeHelper 7 | HOUR = 1000 * 60 * 60 8 | MINUTE = 1000 * 60 9 | SECONDS = 1000 10 | 11 | def self.duration(milliseconds) 12 | parts = [ 13 | milliseconds / 1000 / 60 / 60, # hours 14 | milliseconds / 1000 / 60 % 60, # minutes 15 | milliseconds / 1000 % 60, # seconds 16 | ] 17 | 18 | parts.shift if parts.first.zero? 19 | 20 | [parts.first, *parts[1..-1].map { |part| format('%02d', part) }].join('.') 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/canvas.rb: -------------------------------------------------------------------------------- 1 | require 'curses' 2 | 3 | require_relative 'color' 4 | 5 | module Soundcloud2000 6 | module UI 7 | # initializes our Curses canvas for drawing on 8 | class Canvas 9 | def initialize 10 | Curses.noecho # do not show typed keys 11 | Curses.stdscr.keypad(true) # enable arrow keys 12 | Curses.init_screen 13 | Color.init 14 | end 15 | 16 | def close 17 | Curses.close_screen 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/color.rb: -------------------------------------------------------------------------------- 1 | require 'curses' 2 | 3 | module Soundcloud2000 4 | module UI 5 | # this class stores our text color configurations 6 | class Color 7 | PAIRS = { 8 | white: 0, 9 | red: 1, 10 | blue: 2, 11 | green: 3, 12 | cyan: 4 13 | } 14 | 15 | DEFINITION = { 16 | PAIRS[:white] => [Curses::COLOR_WHITE, Curses::COLOR_BLACK], 17 | PAIRS[:red] => [Curses::COLOR_RED, Curses::COLOR_BLACK], 18 | PAIRS[:blue] => [Curses::COLOR_BLUE, Curses::COLOR_WHITE], 19 | PAIRS[:green] => [Curses::COLOR_GREEN, Curses::COLOR_BLACK], 20 | PAIRS[:cyan] => [Curses::COLOR_BLACK, Curses::COLOR_CYAN] 21 | } 22 | 23 | COLORS = { 24 | white: Curses.color_pair(PAIRS[:white]), 25 | black: Curses.color_pair(PAIRS[:white]) | Curses::A_REVERSE, 26 | red: Curses.color_pair(PAIRS[:red]), 27 | blue: Curses.color_pair(PAIRS[:blue]), 28 | green: Curses.color_pair(PAIRS[:green]), 29 | green_reverse: Curses.color_pair(PAIRS[:green]) | Curses::A_REVERSE, 30 | cyan: Curses.color_pair(PAIRS[:cyan]) 31 | } 32 | 33 | def self.init 34 | Curses.start_color 35 | 36 | DEFINITION.each do |definition, (color, background)| 37 | Curses.init_pair(definition, color, background) 38 | end 39 | end 40 | 41 | def self.get(name) 42 | COLORS[name] 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/input.rb: -------------------------------------------------------------------------------- 1 | require 'curses' 2 | require_relative 'color' 3 | 4 | module Soundcloud2000 5 | module UI 6 | # handles getting input from the user 7 | class Input 8 | MAPPING = { 9 | Curses::KEY_LEFT => :left, 10 | Curses::KEY_RIGHT => :right, 11 | Curses::KEY_DOWN => :down, 12 | Curses::KEY_UP => :up, 13 | Curses::KEY_CTRL_J => :enter, 14 | Curses::KEY_ENTER => :enter, 15 | ' ' => :space, 16 | 'j' => :j, 17 | 'k' => :k, 18 | 's' => :s, 19 | 'u' => :u, 20 | '1' => :one, 21 | '2' => :two, 22 | '3' => :three, 23 | '4' => :four, 24 | '5' => :five, 25 | '6' => :six, 26 | '7' => :seven, 27 | '8' => :eight, 28 | '9' => :nine, 29 | 'f' => :f 30 | } 31 | 32 | def self.get(delay = 0) 33 | Curses.timeout = delay 34 | MAPPING[Curses.getch] 35 | end 36 | 37 | def self.getstr(prompt) 38 | Curses.setpos(Curses.lines - 1, 0) 39 | Curses.clrtoeol 40 | Curses.addstr(prompt) 41 | Curses.echo 42 | result = Curses.getstr 43 | Curses.noecho 44 | Curses.setpos(Curses.lines - 1, 0) 45 | Curses.addstr(''.ljust(Curses.cols)) 46 | result 47 | end 48 | 49 | def self.error(output) 50 | Curses.setpos(Curses.lines - 1, 0) 51 | Curses.clrtoeol 52 | Curses.attron(Color.get(:red)) { Curses.addstr(output) } 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/rect.rb: -------------------------------------------------------------------------------- 1 | module Soundcloud2000 2 | module UI 3 | class Rect 4 | attr_reader :x, :y, :width, :height 5 | 6 | def initialize(x, y, width, height) 7 | @x = x 8 | @y = y 9 | @width = width 10 | @height = height 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/table.rb: -------------------------------------------------------------------------------- 1 | require_relative 'view' 2 | 3 | module Soundcloud2000 4 | module UI 5 | # responsible for drawing our table of tracks 6 | class Table < View 7 | SEPARATOR = ' | ' 8 | 9 | attr_reader :current, :collection 10 | attr_accessor :header, :keys 11 | 12 | def initialize(*args) 13 | super 14 | 15 | @sizes = [] 16 | @rows = [] 17 | @current = 0 18 | @top = 0 19 | @selected = nil 20 | 21 | reset 22 | end 23 | 24 | def bind_to(collection) 25 | fail ArgumentError if @collection 26 | 27 | @collection = collection 28 | @collection.events.on(:append) { render } 29 | @collection.events.on(:replace) { clear; render } 30 | end 31 | 32 | def length 33 | @collection.size 34 | end 35 | 36 | def body_height 37 | rect.height - @header.size 38 | end 39 | 40 | def bottom? 41 | current + 1 >= length 42 | end 43 | 44 | def up 45 | if @current > 0 46 | @current -= 1 47 | @top -= 1 if @current < @top 48 | render 49 | end 50 | end 51 | 52 | def down 53 | if (@current + 1) < length 54 | @current += 1 55 | @top += 1 if @current > body_height 56 | render 57 | end 58 | end 59 | 60 | def select 61 | @selected = @current 62 | render 63 | end 64 | 65 | def deselect 66 | @selected = nil 67 | render 68 | end 69 | 70 | protected 71 | 72 | def rows(start = 0, size = collection.size) 73 | collection[start, size].map do |record| 74 | keys.map { |key| record.send(key).to_s } 75 | end 76 | end 77 | 78 | def rest_width(elements) 79 | rect.width - elements.size * SEPARATOR.size - 80 | elements.inject(0) { |_a, e| + e } 81 | end 82 | 83 | def perform_layout 84 | @sizes = [] 85 | (rows + [header]).each do |row| 86 | row.each_with_index do |value, index| 87 | current, max = value.to_s.length, @sizes[index] || 0 88 | @sizes[index] = current if max < current 89 | end 90 | end 91 | 92 | @sizes[-1] = rest_width(@sizes[0...-1]) 93 | end 94 | 95 | def draw 96 | draw_header 97 | draw_body 98 | end 99 | 100 | def draw_header 101 | with_color(:green_reverse) do 102 | draw_values(header) 103 | end 104 | end 105 | 106 | def color_for(index) 107 | if @top + index == @current 108 | :cyan 109 | elsif @top + index == @selected 110 | :black 111 | else 112 | :white 113 | end 114 | end 115 | 116 | def draw_body 117 | rows(@top, body_height + 1).each_with_index do |row, index| 118 | with_color(color_for(index)) do 119 | draw_values(row) 120 | end 121 | end 122 | end 123 | 124 | def draw_values(values) 125 | i = -1 126 | content = values.map { |value| value.ljust(@sizes[i += 1]) }.join(SEPARATOR) 127 | 128 | line content 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/soundcloud2000/ui/view.rb: -------------------------------------------------------------------------------- 1 | require 'curses' 2 | 3 | require_relative 'color' 4 | 5 | module Soundcloud2000 6 | module UI 7 | # class responsible for helping keep our app tidy and populated 8 | class View 9 | ROW_SEPARATOR = ?| 10 | LINE_SEPARATOR = ?- 11 | INTERSECTION = ?+ 12 | 13 | attr_reader :rect 14 | 15 | def initialize(rect) 16 | @rect = rect 17 | @window = Curses::Window.new(rect.height, rect.width, rect.y, rect.x) 18 | @line = 0 19 | @padding = 0 20 | end 21 | 22 | def padding(value = nil) 23 | value.nil? ? @padding : @padding = value 24 | end 25 | 26 | def render 27 | perform_layout 28 | reset 29 | draw 30 | refresh 31 | end 32 | 33 | def body_width 34 | rect.width - 2 * padding 35 | end 36 | 37 | def with_color(name, &block) 38 | @window.attron(Color.get(name), &block) 39 | end 40 | 41 | def clear 42 | @window.clear 43 | end 44 | 45 | protected 46 | 47 | def lines_left 48 | rect.height - @line - 1 49 | end 50 | 51 | def line(content) 52 | @window.setpos(@line, padding) 53 | @window.addstr(content.ljust(body_width).slice(0, body_width)) 54 | @line += 1 55 | end 56 | 57 | def reset 58 | @line = 0 59 | end 60 | 61 | def refresh 62 | @window.refresh 63 | end 64 | 65 | def perform_layout 66 | end 67 | 68 | def draw 69 | fail NotImplementedError 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/soundcloud2000/views/player_view.rb: -------------------------------------------------------------------------------- 1 | require_relative '../time_helper' 2 | require_relative '../ui/view' 3 | 4 | module Soundcloud2000 5 | module Views 6 | # draws and manages the top section of sc2000, the player 7 | class PlayerView < UI::View 8 | attr_accessor :player 9 | 10 | def initialize(*attrs) 11 | super 12 | 13 | @spectrum = true 14 | padding 2 15 | end 16 | 17 | def toggle_spectrum 18 | @spectrum = !@spectrum 19 | end 20 | 21 | protected 22 | 23 | def draw 24 | line progress + download_progress 25 | with_color(:green) do 26 | line((duration + ' - ' + status).ljust(16) + @player.title) 27 | end 28 | line track_info 29 | line '>' * (@player.level.to_f * body_width).ceil 30 | end 31 | 32 | def status 33 | @player.playing? ? 'playing' : 'paused' 34 | end 35 | 36 | def progress 37 | '#' * (@player.play_progress * body_width).ceil 38 | end 39 | 40 | def download_progress 41 | progress = @player.download_progress - @player.play_progress 42 | 43 | if progress > 0 44 | '.' * (progress * body_width).ceil 45 | else 46 | '' 47 | end 48 | end 49 | 50 | def track 51 | @player.track 52 | end 53 | 54 | def track_info 55 | "#{track.plays} Plays | #{track.likes} Likes | #{track.comments} Comments | #{track.url}" 56 | end 57 | 58 | def duration 59 | TimeHelper.duration(@player.seconds_played.to_i * 1000) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/soundcloud2000/views/splash.rb: -------------------------------------------------------------------------------- 1 | require_relative '../ui/view' 2 | 3 | module Soundcloud2000 4 | module Views 5 | # is responsible for drawing the wonderful splash screen 6 | class Splash < UI::View 7 | CONTENT = %q{ 8 | ohmmNNmmdyoo. 9 | dhMMMMMMMMMMMMMMMms- 10 | :m .MMMMMMMMMMMMMMMMMMMMd- 11 | .o`d: o++./dMy .MMMMMMMMMMMMMMMMMMMMMMd- 12 | /y/M.Mo MmdhMoMy -MMMMMMMMMMMMMMMMMMMMMMMN- 13 | . +N+M-Ms MNddMoMh /:MMMMMMMMMMMMMMMMMMMMMMMMN- 14 | hy yMoM:My MMddMsMd :oMMMMMMMMMMMMMMMMMMMMMMMMMMd 15 | _ dh hMsM/Mh .MMdmMyMd /oMMMMMMMMMMMMMMMMMMMMMMMMMMMMMdhoo 16 | . m N h:Nm dMyM+Md DMMdNMyMm /MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMms. 17 | .m M..M NoMN mMhMsMm dMMdMMhMN +MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN/ 18 | d/MM M/MM /oMhMM /MMdMyMN MMMdMMhMN /MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM- 19 | :MsMM MsMM +MMNMM +MMmMhMM MMMdMMdMM +MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy 20 | /MyMM MyMM +dMNMM dMMmMyMN dMMdMMdMM +oMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMs 21 | N+MM M+MM \+MdMM \NMhMoMm +MMdNMyMm \/MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMm. 22 | \+-N M-.M MsNm dMsM/Mh \MMdmMsMd :MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMh. 23 | + N M m+dy +N+M-Ms MmdhMoMy .MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNy: 24 | `:- -/.+ +. +: -+.o- +/+oooooooooooooooooooooooooooo+/- 25 | 26 | 27 | _ _ _ ___ ___ ___ ___ 28 | | | | | | |__ \ / _ \ / _ \ / _ \ 29 | ___ ___ _ _ _ __ __| | ___| | ___ _ _ __| | ) | | | | | | | | | | 30 | / __|/ _ \| | | | '_ \ / _` |/ __| |/ _ \| | | |/ _` | / /| | | | | | | | | | 31 | \__ \ (_) | |_| | | | | (_| | (__| | (_) | |_| | (_| |/ /_| |_| | |_| | |_| | 32 | |___/\___/ \__,_|_| |_|\__,_|\___|_|\___/ \__,_|\__,_|____|\___/ \___/ \___/ 33 | 34 | Matthias Georgi and Tobias Schmidt 35 | Music Hack Day Stockholm 2013 36 | } 37 | 38 | protected 39 | 40 | def left 41 | (rect.width - lines.map(&:length).max) / 2 42 | end 43 | 44 | def top 45 | (rect.height - lines.size) / 2 46 | end 47 | 48 | def lines 49 | CONTENT.split("\n") 50 | end 51 | 52 | def draw 53 | 0.upto(top) { line '' } 54 | lines.each do |row| 55 | with_color(:green) do 56 | line ' ' * left + row 57 | end 58 | end 59 | end 60 | 61 | def refresh 62 | super 63 | 64 | # show until any keypress 65 | @window.getch 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/soundcloud2000/views/tracks_table.rb: -------------------------------------------------------------------------------- 1 | require_relative '../ui/table' 2 | 3 | module Soundcloud2000 4 | module Views 5 | # this view is responsible for the bar that separates the player and track list 6 | class TracksTable < UI::Table 7 | def initialize(*args) 8 | super 9 | self.header = %w(Title User Length Plays Likes Comments) 10 | self.keys = [:title, :username, :length, :plays, :likes, :comments] 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /soundcloud2000: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT_PATH=$(dirname $0) 4 | 5 | export DYLD_LIBRARY_PATH=$ROOT_PATH/dyld 6 | export RUBYLIB=$ROOT_PATH/lib 7 | 8 | $ROOT_PATH/ruby $ROOT_PATH/bin/soundcloud2000 -------------------------------------------------------------------------------- /soundcloud2000.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "soundcloud2000" 5 | s.version = "0.1.0" 6 | s.authors = ["Tobias Schmidt", "Matthias Georgi"] 7 | s.email = "matti.georgi@gmail.com" 8 | s.homepage = "http://www.github.com/grobie/soundcloud2000" 9 | s.summary = "SoundCloud without the stupid css files" 10 | s.description = "The next generation SoundCloud client" 11 | s.license = 'MIT' 12 | 13 | s.bindir = 'bin' 14 | s.files = `git ls-files`.split("\n") 15 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 16 | s.require_paths = ["lib"] 17 | 18 | s.add_dependency "json", "~> 1.8" 19 | s.add_dependency "audite", "~> 0.4" 20 | s.add_dependency "curses", "~> 1.0" 21 | 22 | s.add_development_dependency "bundler", "~> 1.3" 23 | s.add_development_dependency "rake", "~> 10.5" 24 | s.add_development_dependency "mocha", "~> 1.1" 25 | 26 | s.extra_rdoc_files = ["README.md"] 27 | end 28 | -------------------------------------------------------------------------------- /spec/controllers/track_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require_relative '../../lib/soundcloud2000/controllers/track_controller' 3 | 4 | module Soundcloud2000 5 | module Controllers 6 | describe TrackController do 7 | let(:tracks) { mock } 8 | let(:table) { mock } 9 | let(:client) { mock } 10 | 11 | subject { TrackController.new(table, client) } 12 | 13 | before do 14 | table.expects(:bind_to) 15 | 16 | subject.bind_to(tracks) 17 | end 18 | 19 | it 'plays next track' do 20 | table.expects(:down) 21 | table.expects(:select) 22 | table.expects(:current).returns(mock) 23 | tracks.expects(:[]).returns(mock) 24 | 25 | subject.next_track 26 | end 27 | 28 | it 'on key enter' do 29 | table.expects(:select) 30 | table.expects(:current).returns(mock) 31 | tracks.expects(:[]).returns(mock) 32 | 33 | subject.events.trigger(:key, :enter) 34 | end 35 | 36 | it 'on key up' do 37 | table.expects(:up) 38 | 39 | subject.events.trigger(:key, :up) 40 | end 41 | 42 | it 'on key down' do 43 | table.expects(:down) 44 | table.expects(:bottom?).returns(true) 45 | tracks.expects(:load_more) 46 | 47 | subject.events.trigger(:key, :down) 48 | end 49 | 50 | it 'on key u' do 51 | UI::Input.expects(:getstr).returns(:permalink) 52 | client.expects(:resolve).returns(:user) 53 | tracks.expects(:user=) 54 | 55 | subject.events.trigger(:key, :u) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'mocha' 3 | --------------------------------------------------------------------------------