dtas 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +4 -0
  3. data/Documentation/GNUmakefile +1 -1
  4. data/Documentation/dtas-console.txt +1 -0
  5. data/Documentation/dtas-player_protocol.txt +19 -5
  6. data/Documentation/dtas-splitfx.txt +13 -0
  7. data/Documentation/dtas-tl.txt +16 -0
  8. data/GIT-VERSION-GEN +1 -1
  9. data/GNUmakefile +2 -2
  10. data/INSTALL +3 -3
  11. data/README +4 -0
  12. data/bin/dtas-archive +5 -1
  13. data/bin/dtas-console +13 -6
  14. data/bin/dtas-cueedit +1 -1
  15. data/bin/dtas-mlib +47 -0
  16. data/bin/dtas-readahead +211 -0
  17. data/bin/dtas-sinkedit +1 -1
  18. data/bin/dtas-sourceedit +1 -1
  19. data/bin/dtas-splitfx +15 -6
  20. data/bin/dtas-tl +81 -5
  21. data/dtas.gemspec +2 -2
  22. data/lib/dtas.rb +17 -0
  23. data/lib/dtas/buffer/read_write.rb +21 -19
  24. data/lib/dtas/buffer/splice.rb +1 -2
  25. data/lib/dtas/format.rb +2 -2
  26. data/lib/dtas/mlib.rb +500 -0
  27. data/lib/dtas/mlib/migrations/0001_initial.rb +42 -0
  28. data/lib/dtas/nonblock.rb +24 -0
  29. data/lib/dtas/parse_freq.rb +29 -0
  30. data/lib/dtas/parse_time.rb +5 -2
  31. data/lib/dtas/pipe.rb +2 -1
  32. data/lib/dtas/player.rb +21 -41
  33. data/lib/dtas/player/client_handler.rb +175 -92
  34. data/lib/dtas/process.rb +41 -17
  35. data/lib/dtas/sigevent/pipe.rb +6 -5
  36. data/lib/dtas/sink.rb +1 -1
  37. data/lib/dtas/source/splitfx.rb +14 -0
  38. data/lib/dtas/splitfx.rb +52 -36
  39. data/lib/dtas/track.rb +13 -0
  40. data/lib/dtas/tracklist.rb +148 -43
  41. data/lib/dtas/unix_accepted.rb +49 -32
  42. data/lib/dtas/unix_client.rb +1 -1
  43. data/lib/dtas/unix_server.rb +17 -9
  44. data/lib/dtas/watchable.rb +16 -5
  45. data/test/test_env.rb +16 -0
  46. data/test/test_mlib.rb +31 -0
  47. data/test/test_parse_freq.rb +18 -0
  48. data/test/test_player_client_handler.rb +12 -12
  49. data/test/test_splitfx.rb +0 -29
  50. data/test/test_tracklist.rb +75 -17
  51. data/test/test_unixserver.rb +0 -11
  52. metadata +16 -4
@@ -1,8 +1,10 @@
1
1
  # Copyright (C) 2013-2015 all contributors <dtas-all@nongnu.org>
2
2
  # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
3
  require 'io/wait'
4
+ require 'shellwords'
4
5
  require_relative '../dtas'
5
6
  require_relative 'xs'
7
+ require_relative 'nonblock'
6
8
 
7
9
  # process management helpers
8
10
  module DTAS::Process # :nodoc:
@@ -23,6 +25,7 @@ def self.reaper
23
25
 
24
26
  # expand common shell constructs based on environment variables
25
27
  # this is order-dependent, but Ruby 1.9+ hashes are already order-dependent
28
+ # This recurses
26
29
  def env_expand(env, opts)
27
30
  env = env.dup
28
31
  if false == opts.delete(:expand)
@@ -31,19 +34,40 @@ def env_expand(env, opts)
31
34
  end
32
35
  else
33
36
  env.each do |key, val|
34
- case val
35
- when Numeric # stringify numeric values to simplify users' lives
36
- env[key] = val.to_s
37
- when /[\`\$]/ # perform variable/command expansion
38
- tmp = env.dup
39
- tmp.delete(key)
40
- val = qx(tmp, "echo #{val}", expand: false)
41
- env[key] = val.chomp
37
+ case val = env_expand_i(env, key, val)
38
+ when Array
39
+ val.flatten!
40
+ env[key] = Shellwords.join(val)
42
41
  end
43
42
  end
44
43
  end
45
44
  end
46
45
 
46
+ def env_expand_i(env, key, val)
47
+ case val
48
+ when Numeric # stringify numeric values to simplify users' lives
49
+ env[key] = val.to_s
50
+ when /[\`\$]/ # perform variable/command expansion
51
+ tmp = env.dup
52
+ tmp.delete(key)
53
+ tmp.each do |k,v|
54
+ # best effort, this can get wonky
55
+ tmp[k] = Shellwords.join(v.flatten) if Array === v
56
+ end
57
+ val = qx(tmp, "echo #{val}", expand: false)
58
+ env[key] = val.chomp
59
+ when Array
60
+ env[key] = env_expand_ary(env, key, val)
61
+ else
62
+ val
63
+ end
64
+ end
65
+
66
+ # warning, recursion:
67
+ def env_expand_ary(env, key, val)
68
+ val.map { |v| env_expand_i(env.dup, key, v) }
69
+ end
70
+
47
71
  # for long-running processes (sox/play/ecasound filters)
48
72
  def dtas_spawn(env, cmd, opts)
49
73
  opts = { close_others: true, pgroup: true }.merge!(opts)
@@ -51,7 +75,7 @@ def dtas_spawn(env, cmd, opts)
51
75
 
52
76
  pid = spawn(env, cmd, opts)
53
77
  warn [ :spawn, pid, cmd ].inspect if $DEBUG
54
- @spawn_at = Time.now.to_f
78
+ @spawn_at = DTAS.now
55
79
  PIDS[pid] = self
56
80
  pid
57
81
  end
@@ -63,12 +87,13 @@ def qx(env, cmd = {}, opts = {})
63
87
  cmd, opts = env, cmd
64
88
  env = {}
65
89
  end
66
- r, w = IO.pipe
90
+ buf = ''
91
+ r, w = DTAS::Nonblock.pipe
67
92
  opts = opts.merge(out: w)
68
93
  r.binmode
69
94
  no_raise = opts.delete(:no_raise)
70
95
  if err_str = opts.delete(:err_str)
71
- re, we = IO.pipe
96
+ re, we = DTAS::Nonblock.pipe
72
97
  re.binmode
73
98
  opts[:err] = we
74
99
  end
@@ -82,12 +107,11 @@ def qx(env, cmd = {}, opts = {})
82
107
  begin
83
108
  readable = IO.select(want.keys) or next
84
109
  readable[0].each do |io|
85
- begin
86
- want[io] << io.read_nonblock(2000)
87
- rescue Errno::EAGAIN
88
- # spurious wakeup, bytes may be zero
89
- rescue EOFError
90
- want.delete(io)
110
+ case rv = io.read_nonblock(2000, buf, exception: false)
111
+ when :wait_readable # spurious wakeup, bytes may be zero
112
+ when nil then want.delete(io)
113
+ else
114
+ want[io] << rv
91
115
  end
92
116
  end
93
117
  end until want.empty?
@@ -3,11 +3,13 @@
3
3
 
4
4
  # used in various places for safe wakeups from IO.select via signals
5
5
  # A fallback for non-Linux systems lacking the "sleepy_penguin" RubyGem
6
+ require_relative 'nonblock'
6
7
  class DTAS::Sigevent # :nodoc:
7
8
  attr_reader :to_io
8
9
 
9
10
  def initialize
10
- @to_io, @wr = IO.pipe
11
+ @to_io, @wr = DTAS::Nonblock.pipe
12
+ @rbuf = ''
11
13
  end
12
14
 
13
15
  def signal
@@ -15,11 +17,10 @@ def signal
15
17
  end
16
18
 
17
19
  def readable_iter
18
- begin
19
- @to_io.read_nonblock(11)
20
+ case @to_io.read_nonblock(11, @rbuf, exception: false)
21
+ when :wait_readable then return :wait_readable
22
+ else
20
23
  yield self, nil # calls DTAS::Process.reaper
21
- rescue Errno::EAGAIN
22
- return :wait_readable
23
24
  end while true
24
25
  end
25
26
 
@@ -91,7 +91,7 @@ def sink_spawn(format, opts = {})
91
91
  w.sink = self
92
92
  rv << w
93
93
  end
94
- opts[:in] = "/dev/null"
94
+ opts[:in] = DTAS.null
95
95
 
96
96
  # map to real /dev/fd/* values and setup proper redirects
97
97
  cmd = cmd.gsub(DEVFD_RE) do
@@ -17,6 +17,7 @@ class DTAS::Source::SplitFX < DTAS::Source::Sox # :nodoc:
17
17
 
18
18
  def initialize(sox = DTAS::Source::Sox.new)
19
19
  command_init(SPLITFX_DEFAULTS)
20
+ @watch_extra = []
20
21
  @sox = sox
21
22
  end
22
23
 
@@ -65,6 +66,19 @@ def src_spawn(player_format, rg_state, opts)
65
66
  e = @env.merge!(player_format.to_env)
66
67
  @sfx.infile_env(e, @sox.infile)
67
68
 
69
+ # watch any scripts or files the command in the YAML file refers to
70
+ if c = @sfx.command
71
+ @sfx.expand_cmd(e, c).each do |f|
72
+ File.readable?(f) and @watch_extra << f
73
+ end
74
+ end
75
+
76
+ # allow users to specify explicit depdendencies to watch for edit
77
+ case extra = @ymlhash['deps']
78
+ when Array, String
79
+ @watch_extra.concat(Array(extra))
80
+ end
81
+
68
82
  # make sure these are visible to the "current" command...
69
83
  e["TRIMFX"] = trimfx
70
84
  e["RGFX"] = rg_state.effect(self) || nil
@@ -10,18 +10,28 @@
10
10
  # Unlike the stuff for dtas-player, dtas-splitfx is fairly tied to sox
11
11
  # (but we may still pipe to ecasound or anything else)
12
12
  class DTAS::SplitFX # :nodoc:
13
- CMD = 'sox "$INFILE" $COMMENTS $OUTFMT "$OUTDIR$TRACKNUMBER.$SUFFIX" '\
14
- '$TRIMFX $FX $RATEFX $DITHERFX'
13
+ CMD = 'sox "$INFILE" $COMMENTS $OUTFMT $OUTDST $TRIMFX $FX $RATEFX $DITHERFX'
15
14
  include DTAS::Process
16
15
  include DTAS::XS
17
- attr_reader :infile, :env
16
+ attr_reader :infile, :env, :command
17
+
18
+ # for --trim on the command-line
19
+ class UTrim # :nodoc:
20
+ attr_reader :env, :comments
21
+ def initialize(trim_arg, env, comments)
22
+ @env = env.merge("TRIMFX" => "trim #{trim_arg}")
23
+ @comments = comments.merge('TRACKNUMBER' => '000')
24
+ end
25
+ end
18
26
 
27
+ # declare section to skip
19
28
  class Skip < Struct.new(:tbeg) # :nodoc:
20
29
  def commit(_)
21
30
  # noop
22
31
  end
23
32
  end
24
33
 
34
+ # a standard "track" for splitfx
25
35
  class T < Struct.new(:env, :comments, :tbeg, :fade_in, :fade_out) # :nodoc:
26
36
  def commit(advance_track_samples)
27
37
  tlen = advance_track_samples - tbeg
@@ -69,21 +79,6 @@ def initialize
69
79
  "channels" => 2,
70
80
  },
71
81
  },
72
- "opusenc" => {
73
- "command" => 'sox "$INFILE" $COMMENTS $OUTFMT - ' \
74
- '$TRIMFX $FX $RATEFX $DITHERFX | opusenc --music ' \
75
- '--raw-bits $BITS_PER_SAMPLE ' \
76
- '$OPUSENC_BITRATE --raw-rate $RATE --raw-chan $CHANNELS ' \
77
- '--raw-endianness $ENDIAN_OPUSENC ' \
78
- '$OPUSENC_COMMENTS ' \
79
- '- $OUTDIR$TRACKNUMBER.opus',
80
- "format" => {
81
- "bits" => 16,
82
- "rate" => 48000,
83
- "type" => "s16",
84
- "channels" => 2,
85
- },
86
- },
87
82
  }
88
83
  @tracks = []
89
84
  @infmt = nil # wait until input is assigned
@@ -159,13 +154,15 @@ def load_input!(hash)
159
154
  def generic_target(target = "flac")
160
155
  outfmt = @infmt.dup
161
156
  outfmt.type = target
162
- outfmt.bits = @bits if @bits
163
- outfmt.rate = @rate if @rate
164
157
  { "command" => CMD, "format" => outfmt }
165
158
  end
166
159
 
167
160
  def splitfx_spawn(target, t, opts)
168
- target = @targets[target] || generic_target(target)
161
+ if tgt = @targets[target]
162
+ target = tgt
163
+ else
164
+ target = generic_target(target)
165
+ end
169
166
  outfmt = target["format"]
170
167
 
171
168
  # default format:
@@ -173,6 +170,14 @@ def splitfx_spawn(target, t, opts)
173
170
  outfmt = @infmt.dup
174
171
  outfmt.type = "flac"
175
172
  end
173
+
174
+ outfmt.bits = @bits if @bits
175
+ outfmt.rate = @rate if @rate
176
+
177
+ # player commands will use SOXFMT by default, so we must output that
178
+ # as a self-describing format to the actual encoding instances
179
+ player_cmd = @command
180
+ suffix = outfmt.type
176
181
  env = outfmt.to_env
177
182
 
178
183
  # set very high quality resampling if using 24-bit or higher output
@@ -204,8 +209,9 @@ def splitfx_spawn(target, t, opts)
204
209
  outarg = outfmt.to_sox_arg
205
210
  outarg << "-C#@compression" if @compression
206
211
  env["OUTFMT"] = xs(outarg)
207
- env["SUFFIX"] = outfmt.type
212
+ env["SUFFIX"] = suffix
208
213
  env["OUTDIR"] = @outdir ? "#@outdir/".squeeze('/') : ''
214
+ env["OUTDST"] = opts[:sox_pipe] ? "-p" : "$OUTDIR$TRACKNUMBER.$SUFFIX"
209
215
  env.merge!(t.env)
210
216
 
211
217
  command = target["command"]
@@ -215,22 +221,25 @@ def splitfx_spawn(target, t, opts)
215
221
  # already takes those into account. In other words, use our
216
222
  # target-specific commands like a dtas-player sink:
217
223
  # @command | (INFILE= FX= TRIMFX=; target['command'])
218
- if player_cmd = @command
219
- sub_env = { 'INFILE' => '-', 'FX' => '', 'TRIMFX' => '' }
220
- sub_env_s = sub_env.inject("") { |s,(k,v)| s << "#{k}=#{v} " }
224
+ if player_cmd
225
+ sub_env = {
226
+ 'INFILE' => '-p',
227
+ 'FX' => '',
228
+ 'TRIMFX' => '',
229
+ 'SOXFMT' => ''
230
+ }
231
+ env['SOXFMT'] = '-tsox'
232
+ sub_env['OUTFMT'] = env.delete('OUTFMT')
233
+ sub_env_s = sub_env.inject("") { |s,(k,v)| s << "#{k}=\"#{v}\" " }
234
+ show_cmd = [ expand_cmd(env, player_cmd), '|', '(', "#{sub_env_s};",
235
+ expand_cmd(env.merge(sub_env), command), ')' ].flatten
221
236
  command = "#{player_cmd} | (#{sub_env_s}; #{command})"
222
- show_cmd = [ _expand_cmd(env, player_cmd), '|', '(', "#{sub_env_s};",
223
- _expand_cmd(env.merge(sub_env), command), ')' ].flatten
224
237
  else
225
- show_cmd = _expand_cmd(env, command)
238
+ show_cmd = expand_cmd(env, command)
226
239
  end
227
240
 
228
- echo = "echo #{xs(show_cmd)}"
229
- if opts[:dryrun]
230
- command = echo
231
- else
232
- system(echo) unless opts[:silent]
233
- end
241
+ @out.puts(show_cmd.join(' ')) unless opts[:silent]
242
+ command = 'true' if opts[:dryrun] # still gotta fork
234
243
 
235
244
  # pgroup: false so Ctrl-C on command-line will immediately stop everything
236
245
  [ dtas_spawn(env, command, pgroup: false), comments ]
@@ -339,11 +348,18 @@ def run(target, opts = {})
339
348
  @compression = opts[:compression]
340
349
  @rate = opts[:rate]
341
350
  @bits = opts[:bits]
351
+ trim = opts[:trim] and @tracks = [ UTrim.new(trim, @env, @comments) ]
342
352
 
343
353
  fails = []
344
354
  tracks = @tracks.dup
345
355
  pids = {}
346
356
  jobs = opts[:jobs] || tracks.size # jobs == nil => everything at once
357
+ if opts[:sox_pipe]
358
+ jobs = 1
359
+ @out = $stderr
360
+ else
361
+ @out = $stdout
362
+ end
347
363
  jobs.times.each do
348
364
  t = tracks.shift or break
349
365
  pid, tmp = splitfx_spawn(target, t, opts)
@@ -358,7 +374,7 @@ def run(target, opts = {})
358
374
  pid, tmp = splitfx_spawn(target, t, opts)
359
375
  pids[pid] = [ t, tmp ]
360
376
  end
361
- puts "DONE #{done[0].inspect}" if $DEBUG
377
+ @out.puts "DONE #{done[0].inspect}" if $DEBUG
362
378
  done[1].close!
363
379
  else
364
380
  fails << [ t, status ]
@@ -385,7 +401,7 @@ def infile_env(env, infile)
385
401
  env["INBASE"] = xs(base)
386
402
  end
387
403
 
388
- def _expand_cmd(env, command)
404
+ def expand_cmd(env, command) # for display purposes only
389
405
  Shellwords.split(command).map do |arg|
390
406
  qx(env, "printf %s \"#{arg}\"")
391
407
  end
@@ -0,0 +1,13 @@
1
+ # Copyright (C) 2015 all contributors <dtas-all@nongnu.org>
2
+ # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
+ require_relative '../dtas'
4
+
5
+ class DTAS::Track
6
+ attr_reader :track_id
7
+ attr_reader :to_path
8
+
9
+ def initialize(track_id, path)
10
+ @track_id = track_id
11
+ @to_path = path
12
+ end
13
+ end
@@ -2,97 +2,157 @@
2
2
  # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
3
  require_relative '../dtas'
4
4
  require_relative 'serialize'
5
+ require_relative 'track'
5
6
 
6
7
  # the a tracklist object for -player
7
8
  # this is inspired by the MPRIS 2.0 TrackList spec
8
9
  class DTAS::Tracklist # :nodoc:
9
10
  include DTAS::Serialize
10
11
  attr_accessor :repeat # true, false, 1
12
+ attr_reader :shuffle # false or shuffled @list
13
+ attr_accessor :max # integer
11
14
 
12
- SIVS = %w(list pos repeat)
13
15
  TL_DEFAULTS = {
14
- "list" => [],
15
- "pos" => -1,
16
- "repeat" => false,
16
+ 'pos' => -1,
17
+ 'repeat' => false,
18
+ 'max' => 20_000,
17
19
  }
20
+ SIVS = TL_DEFAULTS.keys
18
21
 
19
22
  def self.load(hash)
20
23
  obj = new
21
24
  obj.instance_eval do
22
- list = hash["list"] and @list.replace(list)
23
- @pos = hash["pos"] || -1
24
- @repeat = hash["repeat"] || false
25
+ list = hash['list'] and @list.replace(list.map { |s| new_track(s) })
26
+ SIVS.each do |k|
27
+ instance_variable_set("@#{k}", hash[k] || TL_DEFAULTS[k])
28
+ end
29
+
30
+ # n.b.: we don't check @list.size against max here in case people
31
+ # are migrating
32
+
33
+ if hash['shuffle']
34
+ @shuffle = @list.shuffle
35
+ @pos = _idx_of(@shuffle, @list[@pos].track_id) if @pos >= 0
36
+ end
25
37
  end
26
38
  obj
27
39
  end
28
40
 
29
- def to_hsh
30
- ivars_to_hash(SIVS).delete_if { |k,v| TL_DEFAULTS[k] == v }
41
+ def to_hsh(full_list = true)
42
+ h = ivars_to_hash(SIVS)
43
+ h.delete_if { |k,v| TL_DEFAULTS[k] == v }
44
+ unless @list.empty?
45
+ if full_list
46
+ h['list'] = @list.map(&:to_path)
47
+ else
48
+ h['size'] = @list.size
49
+ end
50
+ end
51
+ if @shuffle
52
+ h['shuffle'] = true
53
+ h['pos'] = _idx_of(@list, @shuffle[@pos].track_id) if @pos >= 0
54
+ end
55
+ h
31
56
  end
32
57
 
33
58
  def initialize
34
59
  TL_DEFAULTS.each { |k,v| instance_variable_set("@#{k}", v) }
35
60
  @list = []
36
61
  @goto_off = @goto_pos = nil
62
+ @track_nr = 0
63
+ @shuffle = false
64
+ end
65
+
66
+ def new_track(path)
67
+ n = @track_nr += 1
68
+
69
+ # nobody needs a billion tracks in their tracklist, right?
70
+ # avoid promoting to Bignum on 32-bit
71
+ @track_nr = n = 1 if n >= 0x3fffffff
72
+
73
+ DTAS::Track.new(n, path)
37
74
  end
38
75
 
39
76
  def reset
40
77
  @goto_off = @goto_pos = nil
41
78
  @pos = TL_DEFAULTS["pos"]
79
+ @shuffle.shuffle! if @shuffle
42
80
  end
43
81
 
44
- def size
45
- @list.size
82
+ def get_tracks(track_ids)
83
+ want = {}
84
+ track_ids.each { |i| want[i] = i }
85
+ rv = []
86
+ @list.each do |t|
87
+ i = want[t.track_id] and rv << [ i, t.to_path ]
88
+ end
89
+ rv
46
90
  end
47
91
 
48
- # caching this probably isn't worth it. a tracklist is usually
49
- # a few tens of tracks, maybe a hundred at most.
50
- def _track_id_map
51
- by_track_id = {}
52
- @list.each_with_index { |t,i| by_track_id[t.object_id] = i }
53
- by_track_id
92
+ def _update_pos(pos, prev, list)
93
+ old = prev[pos]
94
+ _idx_of(list, old.track_id)
54
95
  end
55
96
 
56
- def get_tracks(track_ids)
57
- by_track_id = _track_id_map
58
- track_ids.map do |track_id|
59
- idx = by_track_id[track_id]
60
- # dtas-mpris fills in the metadata, we just return a path
61
- [ track_id, idx ? @list[idx] : nil ]
97
+ def shuffle=(bool)
98
+ prev = @shuffle
99
+ if bool
100
+ list = @shuffle = (prev ||= @list).shuffle
101
+ elsif prev
102
+ @shuffle = false
103
+ list = @list
104
+ else
105
+ return false
62
106
  end
107
+ @pos = _update_pos(@pos, prev, list) if @pos >= 0
108
+ @goto_pos = _update_pos(@goto_pos, prev, list) if @goto_pos
63
109
  end
64
110
 
65
111
  def tracks
66
- @list.map(&:object_id)
112
+ @list.map(&:track_id)
67
113
  end
68
114
 
69
115
  def advance_track(repeat_ok = true)
70
- return if @list.empty?
116
+ cur = @shuffle || @list
117
+ return if cur.empty?
71
118
  # @repeat == 1 for single track repeat
72
119
  repeat = repeat_ok ? @repeat : false
73
120
  next_pos = @goto_pos || @pos + (repeat == 1 ? 0 : 1)
74
121
  next_off = @goto_off # nil by default
75
122
  @goto_pos = @goto_off = nil
76
- if @list[next_pos]
123
+ if cur[next_pos]
77
124
  @pos = next_pos
78
125
  elsif repeat
79
126
  next_pos = @pos = 0
80
127
  else
81
128
  return
82
129
  end
83
- [ @list[next_pos], next_off ]
130
+ [ cur[next_pos].to_path, next_off ]
84
131
  end
85
132
 
86
133
  def cur_track
87
- @pos >= 0 ? @list[@pos] : nil
134
+ @pos >= 0 ? (@shuffle || @list)[@pos] : nil
88
135
  end
89
136
 
90
137
  def add_track(track, after_track_id = nil, set_as_current = false)
138
+ return false if @list.size >= @max
139
+
140
+ track = new_track(track)
91
141
  if after_track_id
92
- by_track_id = _track_id_map
93
- idx = by_track_id[after_track_id] or
94
- raise ArgumentError, "after_track_id invalid"
142
+ idx = _idx_of(@list, after_track_id) or
143
+ raise ArgumentError, 'after_track_id invalid'
144
+ if @shuffle
145
+ _idx_of(@shuffle, after_track_id) or
146
+ raise ArgumentError, 'after_track_id invalid'
147
+ end
95
148
  @list[idx, 1] = [ @list[idx], track ]
149
+
150
+ # add into random position if shuffling
151
+ if @shuffle
152
+ idx = rand(@shuffle.size)
153
+ @shuffle[idx, 1] = [ @shuffle[idx], track ]
154
+ end
155
+
96
156
  if set_as_current
97
157
  @pos = idx + 1
98
158
  else
@@ -100,33 +160,57 @@ def add_track(track, after_track_id = nil, set_as_current = false)
100
160
  end
101
161
  else # nil = first_track
102
162
  @list.unshift(track)
103
- if set_as_current
104
- @pos = 0
163
+
164
+ if @shuffle
165
+ if @shuffle.empty?
166
+ @shuffle << track
167
+ @pos = 0 if set_as_current
168
+ else
169
+ idx = rand(@shuffle.size)
170
+ @shuffle[idx, 1] = [ @shuffle[idx], track ]
171
+ @pos = idx + 1 if set_as_current
172
+ end
105
173
  else
106
- @pos += 1 if @pos >= 0
174
+ if set_as_current
175
+ @pos = 0
176
+ else
177
+ @pos += 1 if @pos >= 0
178
+ end
107
179
  end
108
180
  end
109
- track.object_id
181
+ track.track_id
182
+ end
183
+
184
+ def _idx_of(list, track_id)
185
+ list.index { |t| t.track_id == track_id }
110
186
  end
111
187
 
112
188
  def remove_track(track_id)
113
- by_track_id = _track_id_map
114
- idx = by_track_id.delete(track_id) or return false
115
- @list[idx] = nil
116
- @list.compact!
189
+ idx = _idx_of(@list, track_id) or return false
190
+ if @shuffle
191
+ si = _idx_of(@shuffle, track_id) or return false
192
+ @shuffle.delete_at(si)
193
+ end
194
+ track = @list.delete_at(idx)
117
195
  len = @list.size
118
196
  if @pos >= len
119
197
  @pos = len == 0 ? TL_DEFAULTS["pos"] : len
120
198
  end
121
199
  @goto_pos = @goto_pos = nil # TODO: reposition?
122
- true
200
+ track.to_path
201
+ end
202
+
203
+ def clear
204
+ @list.clear
205
+ @shuffle.clear if @shuffle
206
+ reset
123
207
  end
124
208
 
125
209
  def go_to(track_id, offset_hhmmss = nil)
126
- by_track_id = _track_id_map
127
- if idx = by_track_id[track_id]
210
+ list = @shuffle || @list
211
+ if idx = _idx_of(list, track_id)
128
212
  @goto_off = offset_hhmmss
129
- return @list[@goto_pos = idx]
213
+ return list[@goto_pos = idx].to_path
130
214
  end
131
215
  @goto_pos = nil
132
216
  # noop if track_id is invalid
@@ -141,4 +225,25 @@ def previous!
141
225
  end
142
226
  @goto_pos = prev_idx
143
227
  end
228
+
229
+ def swap(a_id, b_id)
230
+ ok = { a_id => a_idx = [], b_id => b_idx = [] }
231
+ @list.each_with_index do |t,i|
232
+ ary = ok.delete(t.track_id) or next
233
+ ary[0] = i
234
+ break if ok.empty?
235
+ end
236
+ a_idx = a_idx[0] or return
237
+ b_idx = b_idx[0] or return
238
+ @list[a_idx], @list[b_idx] = @list[b_idx], @list[a_idx]
239
+ unless @shuffle
240
+ [ :@goto_pos, :@pos ].each do |v|
241
+ case instance_variable_get(v)
242
+ when a_idx then instance_variable_set(v, b_idx)
243
+ when b_idx then instance_variable_set(v, a_idx)
244
+ end
245
+ end
246
+ end
247
+ true
248
+ end
144
249
  end