segue 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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +35 -0
- data/.tool-versions +1 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/Rakefile +12 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/exe/segue +59 -0
- data/lib/segue/ffmpeg.rb +75 -0
- data/lib/segue/mpv.rb +111 -0
- data/lib/segue/notifiers.rb +82 -0
- data/lib/segue/paths.rb +36 -0
- data/lib/segue/player.rb +138 -0
- data/lib/segue/player_controller.rb +161 -0
- data/lib/segue/preferences.rb +67 -0
- data/lib/segue/queue.rb +110 -0
- data/lib/segue/scrobbler.rb +136 -0
- data/lib/segue/settings.rb +29 -0
- data/lib/segue/version.rb +5 -0
- data/lib/segue.rb +135 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 654226acfd8c43958144ae5f8bd900a574d9535063718086869a1b7a561fc65e
|
|
4
|
+
data.tar.gz: d575d92843c8dcfc7063e0fe75a753b4f9d342ac32861319d07a9a7841b11658
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e0adbfd5b037b62276113137e8fbaa06f5bd904e7fcca18d69e9978123f8cdd3f335e95a8e3be52abbbb5348293710ffefced2dee955f365c186fce29f6a19cc
|
|
7
|
+
data.tar.gz: ef84a246e55156861643f8a2ca644a70a19473f6627b261fa85099b9718adeaeca096a515db94cf31076a495aa4caa091ecb890c987a0c1f41822e2c14b6b9e6
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.2
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
|
|
6
|
+
Style/StringLiterals:
|
|
7
|
+
Enabled: true
|
|
8
|
+
EnforcedStyle: double_quotes
|
|
9
|
+
|
|
10
|
+
Style/StringLiteralsInInterpolation:
|
|
11
|
+
Enabled: true
|
|
12
|
+
EnforcedStyle: double_quotes
|
|
13
|
+
|
|
14
|
+
# Classes here are small enough to explain themselves
|
|
15
|
+
Style/Documentation:
|
|
16
|
+
Enabled: false
|
|
17
|
+
|
|
18
|
+
Layout/LineLength:
|
|
19
|
+
Max: 120
|
|
20
|
+
|
|
21
|
+
Metrics/MethodLength:
|
|
22
|
+
Max: 25
|
|
23
|
+
|
|
24
|
+
Metrics/AbcSize:
|
|
25
|
+
Max: 25
|
|
26
|
+
|
|
27
|
+
# Player and PlayerController are cohesive - splitting them would only scatter
|
|
28
|
+
# the state they share
|
|
29
|
+
Metrics/ClassLength:
|
|
30
|
+
Max: 130
|
|
31
|
+
|
|
32
|
+
# describe blocks are long by nature
|
|
33
|
+
Metrics/BlockLength:
|
|
34
|
+
Exclude:
|
|
35
|
+
- "spec/**/*"
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 4.0.6
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Ryall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Segue
|
|
2
|
+
|
|
3
|
+
A crossfading queueing daemon for [mpv](https://mpv.io) - kind of like `mpd` but nowhere near as flexible or
|
|
4
|
+
useful.
|
|
5
|
+
|
|
6
|
+
A *segue* is the radio and DJ term for a transition where one track fades into the next, which is the whole
|
|
7
|
+
point of this thing.
|
|
8
|
+
|
|
9
|
+
The player daemon starts two instances of mpv and uses those to play any tracks placed in the queue.
|
|
10
|
+
|
|
11
|
+
Why two instances? mpv doesn't support crossfading between tracks, so this crossfades by starting the other
|
|
12
|
+
mpv instance playing the next track and adjusting the volume of both.
|
|
13
|
+
|
|
14
|
+
This is a rewrite of [vlcraptor](https://github.com/markryall/vlcraptor), which did the same thing with two
|
|
15
|
+
copies of the VLC desktop app. mpv is a better fit: it's a headless binary rather than a `.app` bundle, it
|
|
16
|
+
speaks JSON over a unix socket instead of a line oriented telnet interface, and it runs anywhere rather than
|
|
17
|
+
only where VLC happens to be installed in `/Applications`.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
`brew install mpv` (or your platform's equivalent) - segue looks for `mpv` on your `PATH`.
|
|
22
|
+
|
|
23
|
+
`brew install ffmpeg` is required by the `queue` command for extracting tags to place in the queue.
|
|
24
|
+
|
|
25
|
+
`brew install terminal-notifier` is optional depending on whether you want notifications when new tracks
|
|
26
|
+
start.
|
|
27
|
+
|
|
28
|
+
This gem can be installed with `gem install segue`.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Running `segue` without any parameters will list the available subcommands.
|
|
33
|
+
|
|
34
|
+
### Player
|
|
35
|
+
|
|
36
|
+
Run `segue player` in a shell session.
|
|
37
|
+
|
|
38
|
+
This is the player daemon that controls two instances of mpv. It is an ncurses application which will take
|
|
39
|
+
over the display in that terminal.
|
|
40
|
+
|
|
41
|
+
You can quit with 'q', pause with ' ', stop with 's', play (resume) with 'p' and skip with 'n'.
|
|
42
|
+
|
|
43
|
+
### Adding tracks to the queue
|
|
44
|
+
|
|
45
|
+
`segue queue folder_containing_audio_files audio_file.mp3` will place any number of audio files in the queue
|
|
46
|
+
and the player should immediately start playing the first track.
|
|
47
|
+
|
|
48
|
+
### Viewing queue contents
|
|
49
|
+
|
|
50
|
+
`segue list` will list currently queued tracks with an estimated start time if the player is currently running
|
|
51
|
+
and playing a track.
|
|
52
|
+
|
|
53
|
+
### Managing queue
|
|
54
|
+
|
|
55
|
+
`segue clear` will clear all queued tracks.
|
|
56
|
+
|
|
57
|
+
`segue remove 2` will remove the queued track at index position 2.
|
|
58
|
+
|
|
59
|
+
`segue swap 2 4` will swap the tracks at index positions 2 and 4.
|
|
60
|
+
|
|
61
|
+
### Media controls
|
|
62
|
+
|
|
63
|
+
`segue pause` will pause, `segue stop` will stop and `segue play` will resume.
|
|
64
|
+
|
|
65
|
+
`segue skip` will fade out the current track and start the next one (unless the queue is empty).
|
|
66
|
+
|
|
67
|
+
### Optional features
|
|
68
|
+
|
|
69
|
+
A number of features can be turned on/off while the player is running that will determine certain behaviour:
|
|
70
|
+
|
|
71
|
+
`segue autoplay off` will cause the player to stop and politely wait after the current track has finished.
|
|
72
|
+
`segue autoplay on` and tracks will start playing again.
|
|
73
|
+
|
|
74
|
+
`segue crossfade off` will turn off crossfading so new tracks will start once the previous one is completely
|
|
75
|
+
finished.
|
|
76
|
+
|
|
77
|
+
`segue scrobble on` will turn on last.fm scrobbling - you will require your own application api key and secret
|
|
78
|
+
to enable this.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Variable | Default | Purpose |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| `SEGUE_HOME` | `~/.segue` | Where preferences, history and the queue are kept |
|
|
85
|
+
| `SEGUE_MPV` | `mpv` | The mpv executable to launch |
|
|
86
|
+
|
|
87
|
+
Keeping all state under one directory means segue can be run alongside vlcraptor (or a second copy of itself,
|
|
88
|
+
with a different `SEGUE_HOME`) without the two fighting over the same queue.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
93
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
94
|
+
|
|
95
|
+
The mpv IPC client is covered by specs that talk to a fake mpv over a unix socket, so the test suite does not
|
|
96
|
+
require mpv to be installed.
|
|
97
|
+
|
|
98
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update
|
|
99
|
+
the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for
|
|
100
|
+
the version, push git commits and the created tag, and push the `.gem` file to
|
|
101
|
+
[rubygems.org](https://rubygems.org).
|
|
102
|
+
|
|
103
|
+
## Contributing
|
|
104
|
+
|
|
105
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/markryall/segue.
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "segue"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
require "irb"
|
|
11
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/exe/segue
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), "..", "lib")
|
|
6
|
+
|
|
7
|
+
require "segue"
|
|
8
|
+
|
|
9
|
+
command = ARGV.shift
|
|
10
|
+
|
|
11
|
+
case command
|
|
12
|
+
when "autoplay"
|
|
13
|
+
Segue.autoplay(ARGV.shift)
|
|
14
|
+
when "clear"
|
|
15
|
+
Segue.clear
|
|
16
|
+
when "crossfade"
|
|
17
|
+
Segue.crossfade(ARGV.shift)
|
|
18
|
+
when "history"
|
|
19
|
+
Segue.history
|
|
20
|
+
when "list"
|
|
21
|
+
Segue.list
|
|
22
|
+
when "pause"
|
|
23
|
+
Segue.pause
|
|
24
|
+
when "play"
|
|
25
|
+
Segue.play
|
|
26
|
+
when "player"
|
|
27
|
+
Segue.player
|
|
28
|
+
when "remove"
|
|
29
|
+
Segue.remove(ARGV.shift)
|
|
30
|
+
when "queue"
|
|
31
|
+
Segue.queue(ARGV)
|
|
32
|
+
when "scrobble"
|
|
33
|
+
Segue.scrobble(ARGV.shift)
|
|
34
|
+
when "skip"
|
|
35
|
+
Segue.skip
|
|
36
|
+
when "stop"
|
|
37
|
+
Segue.stop
|
|
38
|
+
when "swap"
|
|
39
|
+
Segue.swap(ARGV)
|
|
40
|
+
when "version"
|
|
41
|
+
puts Segue::VERSION
|
|
42
|
+
else
|
|
43
|
+
puts "Unknown command \"#{command}\":"
|
|
44
|
+
puts " autoplay on/off: continue playing tracks or stop at the end of current track"
|
|
45
|
+
puts " clear: clear queue"
|
|
46
|
+
puts " crossfade on/off: 5 second crossfade when changing tracks"
|
|
47
|
+
puts " history: display play history"
|
|
48
|
+
puts " list: list current queue"
|
|
49
|
+
puts " pause: pause current track (resume with play)"
|
|
50
|
+
puts " play: resume after pause/stop"
|
|
51
|
+
puts " player: start the player"
|
|
52
|
+
puts " queue paths: queue folders or files containing music tracks"
|
|
53
|
+
puts " remove a: remove track from queue at index position a"
|
|
54
|
+
puts " scrobble on/off: send track information to last.fm (requires an api key)"
|
|
55
|
+
puts " skip: skip the current track"
|
|
56
|
+
puts " stop: stop the player (resume with play)"
|
|
57
|
+
puts " swap a b: swap tracks in queue at index positions a and b"
|
|
58
|
+
puts " version: print the segue version"
|
|
59
|
+
end
|
data/lib/segue/ffmpeg.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Segue
|
|
6
|
+
# Pulls tags out of a file by parsing what ffmpeg prints about it.
|
|
7
|
+
class Ffmpeg
|
|
8
|
+
attr_reader(
|
|
9
|
+
:title,
|
|
10
|
+
:album,
|
|
11
|
+
:artist,
|
|
12
|
+
:albumartist,
|
|
13
|
+
:time,
|
|
14
|
+
:date,
|
|
15
|
+
:track,
|
|
16
|
+
:puid,
|
|
17
|
+
:mbartistid,
|
|
18
|
+
:mbalbumid,
|
|
19
|
+
:mbalbumartistid,
|
|
20
|
+
:asin
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def initialize(path)
|
|
24
|
+
@path = path
|
|
25
|
+
parse
|
|
26
|
+
|
|
27
|
+
@title = tag :title, :tit2
|
|
28
|
+
@album = tag :album, :talb
|
|
29
|
+
@artist = tag :artist, :tpe1, :tpe2
|
|
30
|
+
@albumartist = tag :album_artist, :tso2
|
|
31
|
+
@time = to_duration tag :duration
|
|
32
|
+
@date = tag :date, :tdrc, :tyer
|
|
33
|
+
@track = tag :track, :trck
|
|
34
|
+
@puid = tag :"musicip puid"
|
|
35
|
+
@mbartistid = tag :musicbrainz_artistid, :"musicbrainz artist id"
|
|
36
|
+
@mbalbumid = tag :musicbrainz_albumid, :"musicbrainz album id"
|
|
37
|
+
@mbalbumartistid = tag :musicbrainz_albumartistid, :"musicbrainz album artist id"
|
|
38
|
+
@asin = tag :asin
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tag(*names)
|
|
42
|
+
names.each { |name| return @meta[name] if @meta[name] }
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def parse
|
|
49
|
+
@meta = {}
|
|
50
|
+
collecting = false
|
|
51
|
+
`ffmpeg -i #{Shellwords.escape(@path)} 2>&1`.each_line do |line|
|
|
52
|
+
if line.chomp == " Metadata:"
|
|
53
|
+
collecting = true
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
next unless collecting
|
|
57
|
+
|
|
58
|
+
match = / *: */.match line.chomp
|
|
59
|
+
add_meta match.pre_match.strip.downcase.to_sym, match.post_match.strip if match
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add_meta(key, value)
|
|
64
|
+
@meta[key] ||= value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_duration(string)
|
|
68
|
+
return nil unless string
|
|
69
|
+
|
|
70
|
+
first, = string.split ","
|
|
71
|
+
hours, minutes, seconds = first.split ":"
|
|
72
|
+
seconds.to_i + (minutes.to_i * 60) + (hours.to_i * 60 * 60)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/segue/mpv.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Segue
|
|
7
|
+
# Client for mpv's JSON IPC protocol.
|
|
8
|
+
#
|
|
9
|
+
# mpv speaks newline delimited JSON over a unix socket. Requests carry a
|
|
10
|
+
# request_id and replies echo it back, which is what lets us ignore the
|
|
11
|
+
# asynchronous event messages mpv interleaves with responses.
|
|
12
|
+
class Mpv
|
|
13
|
+
class Error < RuntimeError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
CONNECT_TIMEOUT = 5
|
|
17
|
+
LOAD_TIMEOUT = 5
|
|
18
|
+
POLL_INTERVAL = 0.05
|
|
19
|
+
|
|
20
|
+
attr_reader :socket_path
|
|
21
|
+
|
|
22
|
+
def initialize(socket_path)
|
|
23
|
+
@socket_path = socket_path
|
|
24
|
+
@request_id = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# mpv creates the socket a moment after it starts, so retry until it shows up.
|
|
28
|
+
def connect(timeout: CONNECT_TIMEOUT)
|
|
29
|
+
deadline = Time.now + timeout
|
|
30
|
+
begin
|
|
31
|
+
@socket = UNIXSocket.new(socket_path)
|
|
32
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
33
|
+
raise Error, "timed out waiting for mpv socket at #{socket_path}" if Time.now > deadline
|
|
34
|
+
|
|
35
|
+
sleep POLL_INTERVAL
|
|
36
|
+
retry
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def connected?
|
|
42
|
+
!@socket.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def command(*args)
|
|
46
|
+
reply = request(args)
|
|
47
|
+
raise Error, "#{args.first}: #{reply["error"]}" unless reply["error"] == "success"
|
|
48
|
+
|
|
49
|
+
reply["data"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns nil when mpv considers a property unavailable - time-pos and
|
|
53
|
+
# duration are both unavailable whenever nothing is loaded.
|
|
54
|
+
def get(property)
|
|
55
|
+
request(["get_property", property])["data"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def set(property, value)
|
|
59
|
+
command("set_property", property, value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load(path, start: nil)
|
|
63
|
+
command("loadfile", path, "replace")
|
|
64
|
+
wait_for_load
|
|
65
|
+
command("seek", start, "absolute") if start&.positive?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def close
|
|
69
|
+
return unless @socket
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
request(["quit"])
|
|
73
|
+
rescue Error, IOError, SystemCallError
|
|
74
|
+
# mpv may have gone already - nothing left to ask politely
|
|
75
|
+
end
|
|
76
|
+
@socket.close unless @socket.closed?
|
|
77
|
+
@socket = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def wait_for_load(timeout: LOAD_TIMEOUT)
|
|
83
|
+
deadline = Time.now + timeout
|
|
84
|
+
sleep POLL_INTERVAL while get("time-pos").nil? && Time.now < deadline
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request(args)
|
|
88
|
+
raise Error, "not connected to #{socket_path}" unless @socket
|
|
89
|
+
|
|
90
|
+
@request_id += 1
|
|
91
|
+
@socket.puts JSON.generate(command: args, request_id: @request_id)
|
|
92
|
+
read_reply(@request_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read_reply(id)
|
|
96
|
+
loop do
|
|
97
|
+
line = @socket.gets
|
|
98
|
+
raise Error, "mpv closed the connection" if line.nil?
|
|
99
|
+
|
|
100
|
+
message = parse(line)
|
|
101
|
+
return message if message && message["request_id"] == id
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse(line)
|
|
106
|
+
JSON.parse(line)
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rainbow"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "paths"
|
|
6
|
+
require_relative "preferences"
|
|
7
|
+
require_relative "scrobbler"
|
|
8
|
+
|
|
9
|
+
module Segue
|
|
10
|
+
class Notifiers
|
|
11
|
+
def initialize
|
|
12
|
+
@preferences = Segue::Preferences.new
|
|
13
|
+
@history = Segue::Paths.history
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def track_suspended
|
|
17
|
+
@preferences[:started] = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def track_resumed(track, elapsed)
|
|
21
|
+
return unless track
|
|
22
|
+
|
|
23
|
+
track[:start_time] = Time.now - elapsed
|
|
24
|
+
@preferences[:started] = track[:start_time].to_i
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def track_started(track)
|
|
28
|
+
return unless track
|
|
29
|
+
|
|
30
|
+
track[:start_time] = Time.now
|
|
31
|
+
@preferences[:started] = track[:start_time].to_i
|
|
32
|
+
scrobbler&.now_playing(track[:artist], track[:title])
|
|
33
|
+
terminal_notify(
|
|
34
|
+
message: "#{track[:title]} by #{track[:artist]}",
|
|
35
|
+
title: "Now Playing"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
File.open(@history, "a") { |file| file.puts history_line(track) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def track_finished(track)
|
|
42
|
+
@preferences[:started] = nil
|
|
43
|
+
|
|
44
|
+
return unless track
|
|
45
|
+
|
|
46
|
+
scrobbler&.scrobble(track[:artist], track[:title], timestamp: track[:start_time].to_i)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def history_line(track)
|
|
52
|
+
[
|
|
53
|
+
display_time(track[:start_time]),
|
|
54
|
+
Rainbow(track[:title]).green,
|
|
55
|
+
"by",
|
|
56
|
+
Rainbow(track[:artist]).yellow,
|
|
57
|
+
"from",
|
|
58
|
+
Rainbow(track[:album]).cyan,
|
|
59
|
+
"(#{duration(track[:length])})"
|
|
60
|
+
].join(" ")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def duration(seconds)
|
|
64
|
+
seconds = seconds.to_i
|
|
65
|
+
seconds > 60 ? "#{seconds / 60}m and #{seconds % 60}s" : "#{seconds}s"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def display_time(time)
|
|
69
|
+
time.strftime("%I:%M:%S")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def terminal_notify(message:, title:)
|
|
73
|
+
return if `which terminal-notifier`.empty?
|
|
74
|
+
|
|
75
|
+
`terminal-notifier -group segue -message #{Shellwords.escape(message)} -title #{Shellwords.escape(title)}`
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def scrobbler
|
|
79
|
+
Segue::Scrobbler.load if @preferences.scrobble?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/segue/paths.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Segue
|
|
7
|
+
# Everything segue writes to disk lives under a single directory so that it
|
|
8
|
+
# can run alongside other players without fighting over state.
|
|
9
|
+
module Paths
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def home
|
|
13
|
+
File.expand_path(ENV.fetch("SEGUE_HOME", "~/.segue")).tap do |path|
|
|
14
|
+
FileUtils.mkdir_p path
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def preferences
|
|
19
|
+
File.join(home, "preferences.yml")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def history
|
|
23
|
+
File.join(home, "history")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def queue
|
|
27
|
+
File.join(home, "queue").tap { |path| FileUtils.mkdir_p path }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sockets are scoped to the running player so a crashed one can't leave a
|
|
31
|
+
# stale path behind that the next player would try to talk to.
|
|
32
|
+
def socket(name)
|
|
33
|
+
File.join(Dir.tmpdir, "segue-#{name}-#{Process.pid}.sock")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|