dtas 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Documentation/GNUmakefile +2 -0
- data/Documentation/dtas-player_protocol.txt +28 -1
- data/Documentation/dtas-splitfx.txt +167 -0
- data/Documentation/dtas-tl.txt +77 -0
- data/GIT-VERSION-GEN +2 -1
- data/GNUmakefile +1 -1
- data/README +2 -1
- data/Rakefile +7 -0
- data/bin/dtas-console +11 -1
- data/bin/dtas-splitfx +40 -0
- data/bin/dtas-tl +73 -0
- data/examples/README +3 -0
- data/examples/splitfx.sample.yml +19 -0
- data/lib/dtas/format.rb +11 -0
- data/lib/dtas/pipe.rb +0 -3
- data/lib/dtas/player.rb +38 -11
- data/lib/dtas/player/client_handler.rb +94 -7
- data/lib/dtas/process.rb +25 -3
- data/lib/dtas/sink.rb +0 -3
- data/lib/dtas/source/sox.rb +2 -1
- data/lib/dtas/splitfx.rb +342 -0
- data/lib/dtas/tracklist.rb +130 -0
- data/test/helper.rb +14 -1
- data/test/player_integration.rb +5 -3
- data/test/test_buffer.rb +4 -2
- data/test/test_env.rb +55 -0
- data/test/test_format.rb +1 -1
- data/test/test_format_change.rb +1 -1
- data/test/test_player.rb +1 -1
- data/test/test_player_client_handler.rb +1 -1
- data/test/test_player_integration.rb +3 -2
- data/test/test_process.rb +1 -1
- data/test/test_rg_integration.rb +4 -5
- data/test/test_rg_state.rb +1 -1
- data/test/test_sink.rb +1 -1
- data/test/test_sink_pipe_size.rb +1 -1
- data/test/test_sink_tee_integration.rb +1 -1
- data/test/test_source_av.rb +1 -1
- data/test/test_source_sox.rb +1 -1
- data/test/test_splitfx.rb +79 -0
- data/test/test_tracklist.rb +76 -0
- data/test/test_unixserver.rb +1 -1
- data/test/test_util.rb +1 -1
- metadata +23 -3
data/lib/dtas/sink.rb
CHANGED
@@ -7,7 +7,6 @@ require_relative 'process'
|
|
7
7
|
require_relative 'command'
|
8
8
|
require_relative 'format'
|
9
9
|
require_relative 'serialize'
|
10
|
-
require_relative 'writable_iter'
|
11
10
|
|
12
11
|
# this is a sink (endpoint, audio enters but never leaves)
|
13
12
|
class DTAS::Sink # :nodoc:
|
@@ -20,7 +19,6 @@ class DTAS::Sink # :nodoc:
|
|
20
19
|
include DTAS::Command
|
21
20
|
include DTAS::Process
|
22
21
|
include DTAS::Serialize
|
23
|
-
include DTAS::WritableIter
|
24
22
|
|
25
23
|
SINK_DEFAULTS = COMMAND_DEFAULTS.merge({
|
26
24
|
"name" => nil, # order matters, this is first
|
@@ -39,7 +37,6 @@ class DTAS::Sink # :nodoc:
|
|
39
37
|
|
40
38
|
def initialize
|
41
39
|
command_init(SINK_DEFAULTS)
|
42
|
-
writable_iter_init
|
43
40
|
@sink = self
|
44
41
|
end
|
45
42
|
|
data/lib/dtas/source/sox.rb
CHANGED
@@ -11,6 +11,7 @@ class DTAS::Source::Sox # :nodoc:
|
|
11
11
|
|
12
12
|
include DTAS::Source::File
|
13
13
|
include DTAS::XS
|
14
|
+
extend DTAS::XS
|
14
15
|
|
15
16
|
SOX_DEFAULTS = COMMAND_DEFAULTS.merge(
|
16
17
|
"command" => 'exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX',
|
@@ -40,7 +41,7 @@ class DTAS::Source::Sox # :nodoc:
|
|
40
41
|
def try(infile, offset = nil)
|
41
42
|
err = ""
|
42
43
|
cmd = %W(soxi -s #{infile})
|
43
|
-
s = qx(@env, cmd, err_str: err, no_raise: true)
|
44
|
+
s = qx(@env.dup, cmd, err_str: err, no_raise: true)
|
44
45
|
return if err =~ /soxi FAIL formats:/
|
45
46
|
self.class.try_to_fail_harder(infile, s, cmd) or return
|
46
47
|
source_file_dup(infile, offset)
|
data/lib/dtas/splitfx.rb
ADDED
@@ -0,0 +1,342 @@
|
|
1
|
+
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
|
2
|
+
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
3
|
+
# Unlike the stuff for dtas-player, dtas-splitfx is fairly tied to sox
|
4
|
+
# (but we may still pipe to ecasound or anything else)
|
5
|
+
require_relative '../dtas'
|
6
|
+
require_relative 'format'
|
7
|
+
require_relative 'process'
|
8
|
+
require_relative 'xs'
|
9
|
+
require 'tempfile'
|
10
|
+
class DTAS::SplitFX # :nodoc:
|
11
|
+
CMD = 'sox "$INFILE" $COMMENTS $OUTFMT "$TRACKNUMBER.$SUFFIX" '\
|
12
|
+
'$TRIMFX $RATEFX $DITHERFX'
|
13
|
+
include DTAS::Process
|
14
|
+
include DTAS::XS
|
15
|
+
|
16
|
+
class Skip < Struct.new(:tstart) # :nodoc:
|
17
|
+
def commit(_)
|
18
|
+
# noop
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class T < Struct.new(:env, :comments, :tstart, :fade_in, :fade_out) # :nodoc:
|
23
|
+
def commit(advance_track_samples)
|
24
|
+
tlen = advance_track_samples - tstart
|
25
|
+
trimfx = "trim #{tstart}s #{tlen}s"
|
26
|
+
if fade_in
|
27
|
+
trimfx << " #{fade_in}"
|
28
|
+
end
|
29
|
+
if fade_out
|
30
|
+
tmp = fade_out.dup
|
31
|
+
fade_out_len = tmp.pop or
|
32
|
+
raise ArgumentError, "fade_out needs a time value"
|
33
|
+
fade_type = tmp.pop # may be nil
|
34
|
+
fade = " fade #{fade_type} 0 #{tlen}s #{fade_out_len}"
|
35
|
+
trimfx << fade
|
36
|
+
end
|
37
|
+
env["TRIMFX"] = trimfx
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# vars:
|
42
|
+
# $CHANNELS (input)
|
43
|
+
# $BITS_PER_SAMPLE (input)
|
44
|
+
def initialize
|
45
|
+
@env = {}
|
46
|
+
@comments = {}
|
47
|
+
@track_start = 1
|
48
|
+
@track_zpad = true
|
49
|
+
@t2s = method(:t2s)
|
50
|
+
@infile = nil
|
51
|
+
@targets = {
|
52
|
+
"flac-cdda" => {
|
53
|
+
"command" => CMD,
|
54
|
+
"format" => {
|
55
|
+
"bits" => 16,
|
56
|
+
"rate" => 44100,
|
57
|
+
"type" => "flac",
|
58
|
+
"channels" => 2,
|
59
|
+
},
|
60
|
+
},
|
61
|
+
"opusenc" => {
|
62
|
+
"command" => 'sox "$INFILE" $COMMENTS $OUTFMT - ' \
|
63
|
+
'$TRIMFX $RATEFX $DITHERFX | opusenc --music ' \
|
64
|
+
'--raw-bits $BITS_PER_SAMPLE ' \
|
65
|
+
'$OPUSENC_BITRATE --raw-rate $RATE --raw-chan $CHANNELS ' \
|
66
|
+
'--raw-endianness $ENDIAN_OPUSENC ' \
|
67
|
+
'$OPUSENC_COMMENTS ' \
|
68
|
+
'- $TRACKNUMBER.opus',
|
69
|
+
"format" => {
|
70
|
+
"bits" => 16,
|
71
|
+
"rate" => 48000,
|
72
|
+
"type" => "s16",
|
73
|
+
"channels" => 2,
|
74
|
+
},
|
75
|
+
},
|
76
|
+
}
|
77
|
+
@tracks = []
|
78
|
+
@infmt = nil # wait until input is assigned
|
79
|
+
end
|
80
|
+
|
81
|
+
def _bool(hash, key)
|
82
|
+
val = hash[key]
|
83
|
+
case val
|
84
|
+
when false, true then yield val
|
85
|
+
when nil # ignore
|
86
|
+
else
|
87
|
+
raise TypeError, "'#{key}' must be boolean (true or false)"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def import(hash, overrides = {})
|
92
|
+
# merge overrides from the command-line
|
93
|
+
overrides.each do |k,v|
|
94
|
+
case v
|
95
|
+
when Hash then hash[k] = (hash[k] || {}).merge(v)
|
96
|
+
else
|
97
|
+
hash[k] = v
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
hash = hash.merge(overrides)
|
102
|
+
case v = hash["track_zpad"]
|
103
|
+
when Integer then @track_zpad = val
|
104
|
+
else
|
105
|
+
_bool(hash, "track_zpad") { |val| @track_zpad = val }
|
106
|
+
end
|
107
|
+
|
108
|
+
_bool(hash, "cdda_align") { |val| @t2s = method(val ? :t2s : :t2s_cdda) }
|
109
|
+
|
110
|
+
case v = hash["track_start"]
|
111
|
+
when Integer then @track_start = v
|
112
|
+
when nil
|
113
|
+
else
|
114
|
+
raise TypeError, "'track_start' must be an integer"
|
115
|
+
end
|
116
|
+
|
117
|
+
%w(comments env targets).each do |key|
|
118
|
+
case val = hash[key]
|
119
|
+
when Hash then instance_variable_get("@#{key}").merge!(val)
|
120
|
+
when nil
|
121
|
+
else
|
122
|
+
raise TypeError, "'#{key}' must be a hash"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
@targets.each_value do |thsh|
|
127
|
+
case tfmt = thsh["format"]
|
128
|
+
when Hash
|
129
|
+
thsh["format"] = DTAS::Format.load(tfmt) unless tfmt.empty?
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
load_input!(hash)
|
134
|
+
load_tracks!(hash)
|
135
|
+
end
|
136
|
+
|
137
|
+
# FIXME: duplicate from dtas/source/sox
|
138
|
+
def precision
|
139
|
+
qx(@env, %W(soxi -p #@infile), err: "/dev/null").to_i # sox.git f4562efd0aa3
|
140
|
+
rescue # fallback to parsing the whole output
|
141
|
+
s = qx(@env, %W(soxi #@infile), err: "/dev/null")
|
142
|
+
s =~ /Precision\s+:\s*(\d+)-bit/n
|
143
|
+
v = $1.to_i
|
144
|
+
return v if v > 0
|
145
|
+
raise TypeError, "could not determine precision for #@infile"
|
146
|
+
end
|
147
|
+
|
148
|
+
def load_input!(hash)
|
149
|
+
@infile = hash["infile"] or raise ArgumentError, "'infile' not specified"
|
150
|
+
if infmt = hash["infmt"] # rarely needed
|
151
|
+
@infmt = DTAS::Format.load(infmt)
|
152
|
+
else # likely
|
153
|
+
@infmt = DTAS::Format.new
|
154
|
+
@infmt.channels = qx(@env, %W(soxi -c #@infile)).to_i
|
155
|
+
@infmt.rate = qx(@env, %W(soxi -r #@infile)).to_i
|
156
|
+
@infmt.bits ||= precision
|
157
|
+
# we don't care for type
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def generic_target(target = "flac")
|
162
|
+
outfmt = @infmt.dup
|
163
|
+
outfmt.type = target
|
164
|
+
{ "command" => CMD, "format" => outfmt }
|
165
|
+
end
|
166
|
+
|
167
|
+
def spawn(target, t, opts)
|
168
|
+
target = @targets[target] || generic_target(target)
|
169
|
+
outfmt = target["format"]
|
170
|
+
env = outfmt.to_env
|
171
|
+
|
172
|
+
# set very high quality resampling if using 24-bit or higher output
|
173
|
+
if outfmt.rate != @infmt.rate
|
174
|
+
if outfmt.bits
|
175
|
+
# set very-high resampling quality for 24-bit outputs
|
176
|
+
quality = "-v" if outfmt.bits >= 24
|
177
|
+
else
|
178
|
+
# assume output bits matches input bits
|
179
|
+
quality = "-v" if @infmt.bits >= 24
|
180
|
+
end
|
181
|
+
env["RATEFX"] = "rate #{quality} #{outfmt.rate}"
|
182
|
+
end
|
183
|
+
|
184
|
+
# add noise-shaped dither for 16-bit (sox manual seems to recommend this)
|
185
|
+
outfmt.bits && outfmt.bits <= 16 and env["DITHERFX"] = "dither -s"
|
186
|
+
comments = Tempfile.new(%W(dtas-splitfx-#{t.comments["TRACKNUMBER"]} .txt))
|
187
|
+
comments.sync = true
|
188
|
+
t.comments.each do |k,v|
|
189
|
+
env[k] = v.to_s
|
190
|
+
comments.puts("#{k}=#{v}")
|
191
|
+
end
|
192
|
+
env["COMMENTS"] = "--comment-file=#{comments.path}"
|
193
|
+
env["INFILE"] = @infile
|
194
|
+
env["OUTFMT"] = xs(outfmt.to_sox_arg)
|
195
|
+
env["SUFFIX"] = outfmt.type
|
196
|
+
env.merge!(t.env)
|
197
|
+
|
198
|
+
command = target["command"]
|
199
|
+
tmp = Shellwords.split(command).map do |arg|
|
200
|
+
qx(env, "printf %s \"#{arg}\"")
|
201
|
+
end
|
202
|
+
echo = "echo #{xs(tmp)}"
|
203
|
+
if opts[:dryrun]
|
204
|
+
command = echo
|
205
|
+
else
|
206
|
+
system(echo) unless opts[:silent]
|
207
|
+
end
|
208
|
+
|
209
|
+
# pgroup: false so Ctrl-C on command-line will immediately stop everything
|
210
|
+
[ dtas_spawn(env, command, pgroup: false), comments ]
|
211
|
+
end
|
212
|
+
|
213
|
+
def load_tracks!(hash)
|
214
|
+
tracks = hash["tracks"] or raise ArgumentError, "'tracks' not specified"
|
215
|
+
tracks.each { |line| parse_track(Shellwords.split(line)) }
|
216
|
+
|
217
|
+
fmt = "%d"
|
218
|
+
case @track_zpad
|
219
|
+
when true
|
220
|
+
max = @track_start - 1 + @tracks.size
|
221
|
+
fmt = "%0#{max.to_s.size}d"
|
222
|
+
when Integer
|
223
|
+
fmt = "%0#{@track_zpad}d"
|
224
|
+
else
|
225
|
+
fmt = "%d"
|
226
|
+
end
|
227
|
+
nr = @track_start
|
228
|
+
@tracks.delete_if do |t|
|
229
|
+
case t
|
230
|
+
when Skip
|
231
|
+
true
|
232
|
+
else
|
233
|
+
t.comments["TRACKNUMBER"] = sprintf(fmt, nr)
|
234
|
+
nr += 1
|
235
|
+
false
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# argv:
|
241
|
+
# [ 't', '0:05', 'track one', 'fade_in=t 4', '.comment=blah' ]
|
242
|
+
# [ 'stop', '1:00' ]
|
243
|
+
def parse_track(argv)
|
244
|
+
case cmd = argv.shift
|
245
|
+
when "t"
|
246
|
+
start_time = argv.shift
|
247
|
+
title = argv.shift
|
248
|
+
t = T.new
|
249
|
+
t.tstart = @t2s.call(start_time)
|
250
|
+
t.comments = @comments.dup
|
251
|
+
t.comments["TITLE"] = title
|
252
|
+
t.env = @env.dup
|
253
|
+
|
254
|
+
argv.each do |arg|
|
255
|
+
case arg
|
256
|
+
when %r{\Afade_in=(.+)\z}
|
257
|
+
# generate fade-in effect
|
258
|
+
# $1 = "t 4" => "fade t 4 0 0"
|
259
|
+
t.fade_in = "fade #$1 0 0"
|
260
|
+
when %r{\Afade_out=(.+)\z} # $1 = "t 4" or just "4"
|
261
|
+
t.fade_out = $1.split(/\s+/)
|
262
|
+
when %r{\A\.(\w+)=(.+)\z} then t.comments[$1] = $2
|
263
|
+
else
|
264
|
+
raise ArgumentError, "unrecognized arg(s): #{xs(argv)}"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
prev = @tracks.last and prev.commit(t.tstart)
|
269
|
+
@tracks << t
|
270
|
+
when "skip"
|
271
|
+
stop_time = argv.shift
|
272
|
+
argv.empty? or raise ArgumentError, "skip does not take extra args"
|
273
|
+
s = Skip.new
|
274
|
+
s.tstart = @t2s.call(stop_time)
|
275
|
+
# s.comments = {}
|
276
|
+
# s.env = {}
|
277
|
+
prev = @tracks.last or raise ArgumentError, "no tracks to skip"
|
278
|
+
prev.commit(s.tstart)
|
279
|
+
@tracks << s
|
280
|
+
when "stop"
|
281
|
+
stop_time = argv.shift
|
282
|
+
argv.empty? or raise ArgumentError, "stop does not take extra args"
|
283
|
+
samples = @t2s.call(stop_time)
|
284
|
+
prev = @tracks.last and prev.commit(samples)
|
285
|
+
else
|
286
|
+
raise ArgumentError, "unknown command: #{xs(Array(cmd))}"
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# like t2s, but align to CDDA sectors (75 frames per second)
|
291
|
+
def t2s_cdda(time)
|
292
|
+
time = time.dup
|
293
|
+
frac = 0
|
294
|
+
|
295
|
+
# fractions of a second, convert to samples based on sample rate
|
296
|
+
# taking into account CDDA alignment
|
297
|
+
if time.sub!(/\.(\d+)\z/, "")
|
298
|
+
s = "0.#$1".to_f * @infmt.rate / 75
|
299
|
+
frac = s.round * 75
|
300
|
+
end
|
301
|
+
|
302
|
+
# feed the rest to the normal function
|
303
|
+
t2s(time) + frac
|
304
|
+
end
|
305
|
+
|
306
|
+
def t2s(time)
|
307
|
+
@infmt.hhmmss_to_samples(time)
|
308
|
+
end
|
309
|
+
|
310
|
+
def run(target, opts = {})
|
311
|
+
fails = []
|
312
|
+
tracks = @tracks.dup
|
313
|
+
pids = {}
|
314
|
+
jobs = opts[:jobs] || tracks.size # jobs == nil => everything at once
|
315
|
+
jobs.times.each do
|
316
|
+
t = tracks.shift or break
|
317
|
+
pid, tmp = spawn(target, t, opts)
|
318
|
+
pids[pid] = [ t, tmp ]
|
319
|
+
end
|
320
|
+
|
321
|
+
while pids.size > 0
|
322
|
+
pid, status = Process.waitpid2(-1)
|
323
|
+
done = pids.delete(pid)
|
324
|
+
if status.success?
|
325
|
+
if t = tracks.shift
|
326
|
+
pid, tmp = spawn(target, t, opts)
|
327
|
+
pids[pid] = [ t, tmp ]
|
328
|
+
end
|
329
|
+
puts "DONE #{done[0].inspect}" if $DEBUG
|
330
|
+
done[1].close!
|
331
|
+
else
|
332
|
+
fails << [ t, status ]
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
return true if fails.empty? && tracks.empty?
|
337
|
+
fails.each do |(_t,s)|
|
338
|
+
warn "FAIL #{s.inspect} #{_t.inspect}"
|
339
|
+
end
|
340
|
+
false
|
341
|
+
end
|
342
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
|
2
|
+
# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
|
3
|
+
require_relative '../dtas'
|
4
|
+
require_relative 'serialize'
|
5
|
+
|
6
|
+
# this is inspired by the MPRIS 2.0 TrackList spec
|
7
|
+
class DTAS::Tracklist # :nodoc:
|
8
|
+
include DTAS::Serialize
|
9
|
+
attr_accessor :repeat # true, false, 1
|
10
|
+
|
11
|
+
SIVS = %w(list pos repeat)
|
12
|
+
TL_DEFAULTS = {
|
13
|
+
"list" => [],
|
14
|
+
"pos" => -1,
|
15
|
+
"repeat" => false,
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.load(hash)
|
19
|
+
obj = new
|
20
|
+
obj.instance_eval do
|
21
|
+
list = hash["list"] and @list.replace(list)
|
22
|
+
@pos = hash["pos"] || -1
|
23
|
+
@repeat = hash["repeat"] || false
|
24
|
+
end
|
25
|
+
obj
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_hsh
|
29
|
+
ivars_to_hash(SIVS).delete_if { |k,v| TL_DEFAULTS[k] == v }
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize
|
33
|
+
TL_DEFAULTS.each { |k,v| instance_variable_set("@#{k}", v) }
|
34
|
+
@list = []
|
35
|
+
@goto_off = @goto_pos = nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def reset
|
39
|
+
@goto_off = @goto_pos = nil
|
40
|
+
@pos = TL_DEFAULTS["pos"]
|
41
|
+
end
|
42
|
+
|
43
|
+
def size
|
44
|
+
@list.size
|
45
|
+
end
|
46
|
+
|
47
|
+
# caching this probably isn't worth it. a tracklist is usually
|
48
|
+
# a few tens of tracks, maybe a hundred at most.
|
49
|
+
def _track_id_map
|
50
|
+
by_track_id = {}
|
51
|
+
@list.each_with_index { |t,i| by_track_id[t.object_id] = i }
|
52
|
+
by_track_id
|
53
|
+
end
|
54
|
+
|
55
|
+
def get_tracks(track_ids)
|
56
|
+
by_track_id = _track_id_map
|
57
|
+
track_ids.map do |track_id|
|
58
|
+
idx = by_track_id[track_id]
|
59
|
+
# dtas-mpris fills in the metadata, we just return a path
|
60
|
+
[ track_id, idx ? @list[idx] : nil ]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def tracks
|
65
|
+
@list.map { |t| t.object_id }
|
66
|
+
end
|
67
|
+
|
68
|
+
def advance_track(repeat_ok = true)
|
69
|
+
return if @list.empty?
|
70
|
+
# @repeat == 1 for single track repeat
|
71
|
+
next_pos = @goto_pos || @pos + (@repeat == 1 ? 0 : 1)
|
72
|
+
next_off = @goto_off # nil by default
|
73
|
+
@goto_pos = @goto_off = nil
|
74
|
+
if @list[next_pos]
|
75
|
+
@pos = next_pos
|
76
|
+
elsif @repeat && repeat_ok
|
77
|
+
next_pos = @pos = 0
|
78
|
+
else
|
79
|
+
return
|
80
|
+
end
|
81
|
+
[ @list[next_pos], next_off ]
|
82
|
+
end
|
83
|
+
|
84
|
+
def cur_track
|
85
|
+
@pos >= 0 ? @list[@pos] : nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def add_track(track, after_track_id = nil, set_as_current = false)
|
89
|
+
if after_track_id
|
90
|
+
by_track_id = _track_id_map
|
91
|
+
idx = by_track_id[after_track_id] or
|
92
|
+
raise ArgumentError, "after_track_id invalid"
|
93
|
+
@list[idx, 1] = [ @list[idx], track ]
|
94
|
+
@pos = idx + 1 if set_as_current
|
95
|
+
else # nil = first_track
|
96
|
+
@list.unshift(track)
|
97
|
+
@pos = 0 if set_as_current
|
98
|
+
end
|
99
|
+
track.object_id
|
100
|
+
end
|
101
|
+
|
102
|
+
def remove_track(track_id)
|
103
|
+
by_track_id = _track_id_map
|
104
|
+
if idx = by_track_id.delete(track_id)
|
105
|
+
@list[idx] = nil
|
106
|
+
@list.compact!
|
107
|
+
# TODO: what do we do with @pos (and the currently-playing track)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def go_to(track_id, offset_hhmmss = nil)
|
112
|
+
by_track_id = _track_id_map
|
113
|
+
if idx = by_track_id[track_id]
|
114
|
+
@goto_off = offset_hhmmss
|
115
|
+
return @list[@goto_pos = idx]
|
116
|
+
end
|
117
|
+
@goto_pos = nil
|
118
|
+
# noop if track_id is invalid
|
119
|
+
end
|
120
|
+
|
121
|
+
def previous!
|
122
|
+
return if @list.empty?
|
123
|
+
prev_idx = @pos - 1
|
124
|
+
if prev_idx < 0
|
125
|
+
# stop playback if nothing to go back to.
|
126
|
+
prev_idx = @repeat ? @list.size - 1 : @list.size
|
127
|
+
end
|
128
|
+
@goto_pos = prev_idx
|
129
|
+
end
|
130
|
+
end
|