feat(train): marked playlists kept on the phone, their tempo worked out (ADR 0030, D47)
Music's second part: the marked playlists' tracks are downloaded as Jellyfin keeps them, on unmetered networks only, while the app is open or resumes, and deleted once no marked playlist holds them. Each track's tempo is worked out once on the phone: a BPM tag where the file has one, else a minute decoded with MediaCodec and its spectral flux autocorrelated (Tempo.kt, tested on the JVM). The music screen shows what is kept and deletes it after asking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3336fe4eca
commit
7f18f001f4
25 changed files with 1676 additions and 24 deletions
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,9 @@
|
|||
<!-- The app talks to its instance: release builds need this too (Flutter's
|
||||
template grants it to debug and profile builds only). -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Whether the network is metered: music is downloaded on unmetered ones
|
||||
only (ADR 0030, D47). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- Recording a workout (ADR 0030, D13, D19): GPS while the app records in
|
||||
the foreground, never in the background; the step detector for
|
||||
auto-pause on foot; the recording's notification; the processor kept
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package app.halkyon.train
|
||||
|
||||
/**
|
||||
* The tempo a track's file names itself (ADR 0030, D47), taken before Train's own analysis: an
|
||||
* ID3v2 `TBPM` frame (MP3s, versions 2.3 and 2.4) or a `BPM` Vorbis comment (FLAC). Other
|
||||
* containers (MP4's `tmpo`, Ogg) are analysed. A tag outside 40 to 250 beats per minute is
|
||||
* taken for a wrong one.
|
||||
*/
|
||||
object BpmTag {
|
||||
/** The tempo in [head], the file's first bytes; null where it names none. */
|
||||
fun of(head: ByteArray): Double? =
|
||||
when {
|
||||
head.startsWith("ID3") -> id3(head)
|
||||
head.startsWith("fLaC") -> flac(head)
|
||||
else -> null
|
||||
}?.takeIf { it in 40.0..250.0 }
|
||||
|
||||
private fun ByteArray.startsWith(magic: String) =
|
||||
size >= magic.length && magic.indices.all { this[it] == magic[it].code.toByte() }
|
||||
|
||||
private fun ByteArray.u8(at: Int) = this[at].toInt() and 0xff
|
||||
|
||||
private fun synchsafe(bytes: ByteArray, at: Int) =
|
||||
(bytes.u8(at) shl 21) or (bytes.u8(at + 1) shl 14) or (bytes.u8(at + 2) shl 7) or bytes.u8(at + 3)
|
||||
|
||||
private fun bigEndian(bytes: ByteArray, at: Int) =
|
||||
(bytes.u8(at) shl 24) or (bytes.u8(at + 1) shl 16) or (bytes.u8(at + 2) shl 8) or bytes.u8(at + 3)
|
||||
|
||||
private fun littleEndian(bytes: ByteArray, at: Int) =
|
||||
bytes.u8(at) or (bytes.u8(at + 1) shl 8) or (bytes.u8(at + 2) shl 16) or (bytes.u8(at + 3) shl 24)
|
||||
|
||||
private fun number(text: String) = text.trim().replace(',', '.').toDoubleOrNull()
|
||||
|
||||
private fun id3(head: ByteArray): Double? {
|
||||
if (head.size < 10) return null
|
||||
val version = head.u8(3)
|
||||
if (version != 3 && version != 4) return null
|
||||
// An extended header or unsynchronisation: rare, and not read.
|
||||
if (head.u8(5) and 0xc0 != 0) return null
|
||||
val end = minOf(head.size, 10 + synchsafe(head, 6))
|
||||
var at = 10
|
||||
while (at + 10 <= end) {
|
||||
val id = String(head, at, 4, Charsets.ISO_8859_1)
|
||||
if (id[0] == '\u0000') return null
|
||||
val size = if (version == 4) synchsafe(head, at + 4) else bigEndian(head, at + 4)
|
||||
if (size <= 0 || at + 10 + size > end) return null
|
||||
if (id == "TBPM") {
|
||||
val text = head.copyOfRange(at + 11, at + 10 + size)
|
||||
val charset =
|
||||
when (head.u8(at + 10)) {
|
||||
1 -> Charsets.UTF_16
|
||||
2 -> Charsets.UTF_16BE
|
||||
3 -> Charsets.UTF_8
|
||||
else -> Charsets.ISO_8859_1
|
||||
}
|
||||
return number(String(text, charset).trimEnd('\u0000'))
|
||||
}
|
||||
at += 10 + size
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun flac(head: ByteArray): Double? {
|
||||
var at = 4
|
||||
while (at + 4 <= head.size) {
|
||||
val last = head.u8(at) and 0x80 != 0
|
||||
val type = head.u8(at) and 0x7f
|
||||
val length = (head.u8(at + 1) shl 16) or (head.u8(at + 2) shl 8) or head.u8(at + 3)
|
||||
val start = at + 4
|
||||
if (start + length > head.size) return null
|
||||
if (type == 4) return vorbisComment(head, start, start + length)
|
||||
if (last) return null
|
||||
at = start + length
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun vorbisComment(head: ByteArray, from: Int, to: Int): Double? {
|
||||
var at = from
|
||||
if (at + 4 > to) return null
|
||||
at += 4 + littleEndian(head, at)
|
||||
if (at + 4 > to) return null
|
||||
val count = littleEndian(head, at)
|
||||
at += 4
|
||||
repeat(count) {
|
||||
if (at + 4 > to) return null
|
||||
val length = littleEndian(head, at)
|
||||
at += 4
|
||||
if (length < 0 || at + length > to) return null
|
||||
val comment = String(head, at, length, Charsets.UTF_8)
|
||||
at += length
|
||||
val equals = comment.indexOf('=')
|
||||
if (equals > 0 && comment.substring(0, equals).equals("BPM", ignoreCase = true)) {
|
||||
return number(comment.substring(equals + 1))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,9 @@ class MainActivity : FlutterActivity() {
|
|||
/** Routes' GPX files, in and out (ADR 0030, D45). */
|
||||
private val files by lazy { RouteFiles(this) }
|
||||
|
||||
/** Music from Jellyfin: the network, and the tempo of what is downloaded (ADR 0030, D47). */
|
||||
private val music by lazy { MusicBridge(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
health.onIntent(intent)
|
||||
|
|
@ -102,6 +105,7 @@ class MainActivity : FlutterActivity() {
|
|||
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
||||
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
||||
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
|
|
@ -122,6 +126,7 @@ class MainActivity : FlutterActivity() {
|
|||
RecorderService.lines = null
|
||||
health.dispose()
|
||||
loop.dispose()
|
||||
music.close()
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
|
||||
* as music is downloaded on unmetered ones only; and a downloaded track's tempo, the tag its
|
||||
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
|
||||
* mono and handed to [Tempo], one track at a time, off the main thread.
|
||||
*/
|
||||
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"unmetered" -> {
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
result.success(connectivity.activeNetwork != null && !connectivity.isActiveNetworkMetered)
|
||||
}
|
||||
"tempo" -> {
|
||||
val path = call.argument<String>("path")!!
|
||||
worker.execute {
|
||||
val tempo =
|
||||
try {
|
||||
of(File(path))
|
||||
} catch (error: Exception) {
|
||||
// Its kind only: never the track's name.
|
||||
Log.w("Music", "Tempo not worked out: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
main.post { result.success(tempo) }
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = worker.shutdownNow()
|
||||
|
||||
companion object {
|
||||
private const val SECONDS = 60
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
|
||||
/** The tempo of the track in [file]; null where neither its tag nor its beat says one. */
|
||||
fun of(file: File): Double? {
|
||||
val head = ByteArray(256 * 1024)
|
||||
var read = 0
|
||||
file.inputStream().use { input ->
|
||||
while (read < head.size) {
|
||||
val got = input.read(head, read, head.size - read)
|
||||
if (got < 0) break
|
||||
read += got
|
||||
}
|
||||
}
|
||||
BpmTag.of(head.copyOf(read))?.let { return it }
|
||||
val (samples, rate) = decode(file) ?: return null
|
||||
return Tempo.of(samples, rate)
|
||||
}
|
||||
|
||||
/** A minute from the middle of [file] in mono, with its sample rate. */
|
||||
private fun decode(file: File): Pair<FloatArray, Int>? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(file.path)
|
||||
val track =
|
||||
(0 until extractor.trackCount).firstOrNull {
|
||||
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: return null
|
||||
val format = extractor.getTrackFormat(track)
|
||||
extractor.selectTrack(track)
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
val duration = format.getLong(MediaFormat.KEY_DURATION)
|
||||
val from = (duration / 2 - SECONDS * 500_000L).coerceAtLeast(0)
|
||||
extractor.seekTo(from, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
||||
}
|
||||
val codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
|
||||
try {
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
return drain(extractor, codec, format)
|
||||
} finally {
|
||||
codec.release()
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drain(extractor: MediaExtractor, codec: MediaCodec, format: MediaFormat): Pair<FloatArray, Int>? {
|
||||
var rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
var floats = false
|
||||
var samples = FloatArray(rate * SECONDS)
|
||||
var count = 0
|
||||
var inputDone = false
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (count < samples.size) {
|
||||
if (!inputDone) {
|
||||
val index = codec.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val size = extractor.readSampleData(codec.getInputBuffer(index)!!, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputDone = true
|
||||
} else {
|
||||
codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
val index = codec.dequeueOutputBuffer(info, TIMEOUT_US)
|
||||
when {
|
||||
index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
val now = codec.outputFormat
|
||||
rate = now.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = now.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
floats =
|
||||
now.containsKey(MediaFormat.KEY_PCM_ENCODING) &&
|
||||
now.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT
|
||||
if (count == 0) samples = FloatArray(rate * SECONDS)
|
||||
}
|
||||
index >= 0 -> {
|
||||
val buffer = codec.getOutputBuffer(index)!!.order(ByteOrder.nativeOrder())
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
if (floats) {
|
||||
val pcm = buffer.asFloatBuffer()
|
||||
while (pcm.remaining() >= channels && count < samples.size) {
|
||||
var sum = 0f
|
||||
repeat(channels) { sum += pcm.get() }
|
||||
samples[count++] = sum / channels
|
||||
}
|
||||
} else {
|
||||
val pcm = buffer.asShortBuffer()
|
||||
while (pcm.remaining() >= channels && count < samples.size) {
|
||||
var sum = 0f
|
||||
repeat(channels) { sum += pcm.get() / 32768f }
|
||||
samples[count++] = sum / channels
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (count == 0) null else samples.copyOf(count) to rate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A track's tempo worked out from its audio (ADR 0030, D47): the onsets as the spectrum's
|
||||
* rise from frame to frame (spectral flux, about 86 frames a second), their autocorrelation
|
||||
* over the lags of 50 to 220 beats per minute, weighed towards 120 by an octave either side
|
||||
* as people tap along, the peak refined between its neighbours. A track the beat of which
|
||||
* repeats too weakly has none. Its half and double fit a cadence too, which the player
|
||||
* counts (10c), so a tempo found an octave off still serves.
|
||||
*/
|
||||
object Tempo {
|
||||
private const val FRAMES_A_SECOND = 86.0
|
||||
private const val SLOWEST = 50.0
|
||||
private const val FASTEST = 220.0
|
||||
|
||||
/** How strongly the beat must repeat: noise stays well below it. */
|
||||
const val STEADY = 0.15
|
||||
|
||||
/** The beats per minute of mono [samples] at [sampleRate]; null where there is no steady beat. */
|
||||
fun of(samples: FloatArray, sampleRate: Int): Double? {
|
||||
val hop = max(1, (sampleRate / FRAMES_A_SECOND).roundToInt())
|
||||
val rate = sampleRate.toDouble() / hop
|
||||
val envelope = onsets(samples, hop)
|
||||
if (envelope.size < rate * 10) return null
|
||||
val mean = envelope.average()
|
||||
val centred = DoubleArray(envelope.size) { envelope[it] - mean }
|
||||
val energy = centred.sumOf { it * it }
|
||||
if (energy <= 0.0) return null
|
||||
val shortest = (rate * 60 / FASTEST).toInt()
|
||||
val longest = (rate * 60 / SLOWEST).toInt() + 1
|
||||
if (longest + 2 >= centred.size) return null
|
||||
val correlation = DoubleArray(longest + 2) { lag ->
|
||||
if (lag == 0) {
|
||||
1.0
|
||||
} else {
|
||||
var sum = 0.0
|
||||
for (i in 0 until centred.size - lag) sum += centred[i] * centred[i + lag]
|
||||
sum / energy * centred.size / (centred.size - lag)
|
||||
}
|
||||
}
|
||||
var best = -1
|
||||
var bestScore = 0.0
|
||||
for (lag in max(shortest, 1)..longest) {
|
||||
val peak = correlation[lag] >= correlation[lag - 1] && correlation[lag] >= correlation[lag + 1]
|
||||
if (!peak) continue
|
||||
val octaves = ln(60 * rate / lag / 120) / ln(2.0)
|
||||
val score = correlation[lag] * exp(-0.5 * octaves * octaves)
|
||||
if (score > bestScore) {
|
||||
best = lag
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if (best < 0 || correlation[best] < STEADY) return null
|
||||
// The parabola through the peak and its neighbours.
|
||||
val left = correlation[best - 1]
|
||||
val middle = correlation[best]
|
||||
val right = correlation[best + 1]
|
||||
val bend = left - 2 * middle + right
|
||||
val shift = if (bend == 0.0) 0.0 else 0.5 * (left - right) / bend
|
||||
return 60 * rate / (best + shift.coerceIn(-0.5, 0.5))
|
||||
}
|
||||
|
||||
/** The spectral flux of [samples], a frame each [hop] samples, its local mean taken off. */
|
||||
internal fun onsets(samples: FloatArray, hop: Int): DoubleArray {
|
||||
var size = 1
|
||||
while (size < hop * 4) size *= 2
|
||||
val window = DoubleArray(size) { 0.5 - 0.5 * cos(2 * PI * it / size) }
|
||||
val frames = if (samples.size < size) 0 else (samples.size - size) / hop + 1
|
||||
val flux = DoubleArray(frames)
|
||||
var before = DoubleArray(size / 2)
|
||||
val real = DoubleArray(size)
|
||||
val imaginary = DoubleArray(size)
|
||||
for (frame in 0 until frames) {
|
||||
val start = frame * hop
|
||||
for (i in 0 until size) {
|
||||
real[i] = samples[start + i] * window[i]
|
||||
imaginary[i] = 0.0
|
||||
}
|
||||
fft(real, imaginary)
|
||||
val now = DoubleArray(size / 2) { ln(1 + 100 * sqrt(real[it] * real[it] + imaginary[it] * imaginary[it])) }
|
||||
if (frame > 0) {
|
||||
var rise = 0.0
|
||||
for (i in now.indices) rise += max(0.0, now[i] - before[i])
|
||||
flux[frame] = rise
|
||||
}
|
||||
before = now
|
||||
}
|
||||
// A rise counts against the frames around it, about 0.4 s, so that loud passages don't
|
||||
// outweigh the beat.
|
||||
val half = (FRAMES_A_SECOND * 0.2).roundToInt()
|
||||
val prefix = DoubleArray(frames + 1)
|
||||
for (i in 0 until frames) prefix[i + 1] = prefix[i] + flux[i]
|
||||
return DoubleArray(frames) {
|
||||
val from = max(0, it - half)
|
||||
val to = minOf(frames, it + half + 1)
|
||||
max(0.0, flux[it] - (prefix[to] - prefix[from]) / (to - from))
|
||||
}
|
||||
}
|
||||
|
||||
/** The discrete Fourier transform of [real] and [imaginary] in place; their size a power of 2. */
|
||||
private fun fft(real: DoubleArray, imaginary: DoubleArray) {
|
||||
val n = real.size
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) {
|
||||
j = j xor bit
|
||||
bit = bit shr 1
|
||||
}
|
||||
j = j xor bit
|
||||
if (i < j) {
|
||||
real[i] = real[j].also { real[j] = real[i] }
|
||||
imaginary[i] = imaginary[j].also { imaginary[j] = imaginary[i] }
|
||||
}
|
||||
}
|
||||
var length = 2
|
||||
while (length <= n) {
|
||||
val angle = -2 * PI / length
|
||||
val stepReal = cos(angle)
|
||||
val stepImaginary = sin(angle)
|
||||
for (start in 0 until n step length) {
|
||||
var turnReal = 1.0
|
||||
var turnImaginary = 0.0
|
||||
for (k in 0 until length / 2) {
|
||||
val a = start + k
|
||||
val b = a + length / 2
|
||||
val bReal = real[b] * turnReal - imaginary[b] * turnImaginary
|
||||
val bImaginary = real[b] * turnImaginary + imaginary[b] * turnReal
|
||||
real[b] = real[a] - bReal
|
||||
imaginary[b] = imaginary[a] - bImaginary
|
||||
real[a] += bReal
|
||||
imaginary[a] += bImaginary
|
||||
val next = turnReal * stepReal - turnImaginary * stepImaginary
|
||||
turnImaginary = turnReal * stepImaginary + turnImaginary * stepReal
|
||||
turnReal = next
|
||||
}
|
||||
}
|
||||
length *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The tempo a file names itself (ADR 0030, D47). */
|
||||
class BpmTagTest {
|
||||
private fun synchsafe(size: Int) =
|
||||
byteArrayOf((size shr 21 and 0x7f).toByte(), (size shr 14 and 0x7f).toByte(), (size shr 7 and 0x7f).toByte(), (size and 0x7f).toByte())
|
||||
|
||||
private fun plain(size: Int) =
|
||||
byteArrayOf((size shr 24).toByte(), (size shr 16).toByte(), (size shr 8).toByte(), size.toByte())
|
||||
|
||||
/** An ID3v2 tag of [version] with a title and then [frames]. */
|
||||
private fun id3(version: Int, vararg frames: Pair<String, ByteArray>): ByteArray {
|
||||
val body = ByteArrayOutputStream()
|
||||
for ((id, content) in listOf("TIT2" to byteArrayOf(0) + "Song".toByteArray()) + frames) {
|
||||
body.write(id.toByteArray())
|
||||
body.write(if (version == 4) synchsafe(content.size) else plain(content.size))
|
||||
body.write(byteArrayOf(0, 0))
|
||||
body.write(content)
|
||||
}
|
||||
val bytes = body.toByteArray()
|
||||
return "ID3".toByteArray() + byteArrayOf(version.toByte(), 0, 0) + synchsafe(bytes.size) + bytes + ByteArray(64)
|
||||
}
|
||||
|
||||
private fun le(value: Int) =
|
||||
byteArrayOf(value.toByte(), (value shr 8).toByte(), (value shr 16).toByte(), (value shr 24).toByte())
|
||||
|
||||
/** A FLAC file's head: its stream info, then a Vorbis comment holding [comments]. */
|
||||
private fun flac(vararg comments: String): ByteArray {
|
||||
val comment = ByteArrayOutputStream()
|
||||
val vendor = "reference libFLAC".toByteArray()
|
||||
comment.write(le(vendor.size))
|
||||
comment.write(vendor)
|
||||
comment.write(le(comments.size))
|
||||
for (text in comments) {
|
||||
val bytes = text.toByteArray()
|
||||
comment.write(le(bytes.size))
|
||||
comment.write(bytes)
|
||||
}
|
||||
val block = comment.toByteArray()
|
||||
return "fLaC".toByteArray() +
|
||||
byteArrayOf(0, 0, 0, 34) + ByteArray(34) +
|
||||
byteArrayOf(0x84.toByte(), (block.size shr 16).toByte(), (block.size shr 8).toByte(), block.size.toByte()) +
|
||||
block
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an MP3's TBPM is read, in ID3 2·3 and 2·4, in Latin-1 and UTF-16`() {
|
||||
assertEquals(128.0, BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "128".toByteArray()))!!, 0.0)
|
||||
assertEquals(
|
||||
172.5,
|
||||
BpmTag.of(id3(4, "TBPM" to byteArrayOf(1) + "172.5".toByteArray(Charsets.UTF_16)))!!,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a FLAC's BPM comment is read, whatever its case`() {
|
||||
assertEquals(96.0, BpmTag.of(flac("TITLE=Song", "bpm=96"))!!, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tag, a nonsense one, an implausible one and other files are none`() {
|
||||
assertNull(BpmTag.of(id3(3)))
|
||||
assertNull(BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "fast".toByteArray())))
|
||||
assertNull(BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "0".toByteArray())))
|
||||
assertNull(BpmTag.of(flac("TITLE=Song")))
|
||||
assertNull(BpmTag.of("OggS and the rest".toByteArray()))
|
||||
assertNull(BpmTag.of(ByteArray(0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tag cut short is none, not a crash`() {
|
||||
val whole = id3(4, "TBPM" to byteArrayOf(0) + "128".toByteArray())
|
||||
for (length in 0..whole.size) BpmTag.of(whole.copyOf(length))
|
||||
assertNull(BpmTag.of(whole.copyOf(20)))
|
||||
assertEquals(128.0, BpmTag.of(whole)!!, 0.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** A track's tempo from its audio (ADR 0030, D47), on beats of known tempo. */
|
||||
class TempoTest {
|
||||
private val rate = 22_050
|
||||
|
||||
/**
|
||||
* [seconds] of a kick on every beat at [bpm], a hi-hat on every off-beat where [hats], over
|
||||
* a steady tone and some noise, as a mix of music has them.
|
||||
*/
|
||||
private fun beats(bpm: Double, seconds: Int = 30, hats: Boolean = false, noise: Float = 0.05f): FloatArray {
|
||||
val random = Random(7)
|
||||
val beat = rate * 60 / bpm
|
||||
return FloatArray(rate * seconds) { i ->
|
||||
val sinceBeat = (i % beat) / rate
|
||||
val sinceHalf = ((i + beat / 2) % beat) / rate
|
||||
val kick = exp(-sinceBeat * 30) * sin(2 * PI * 60 * sinceBeat)
|
||||
val hat = if (hats) exp(-sinceHalf * 80) * (random.nextDouble() - 0.5) * 0.6 else 0.0
|
||||
val tone = 0.2 * sin(2 * PI * 220 * i / rate)
|
||||
(kick + hat + tone).toFloat() + (random.nextFloat() - 0.5f) * noise
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steady kick gives its tempo within a beat a minute`() {
|
||||
for (bpm in listOf(90.0, 128.0, 140.0, 174.0)) {
|
||||
val found = Tempo.of(beats(bpm), rate)
|
||||
assertNotNull("$bpm", found)
|
||||
assertEquals("$bpm", bpm, found!!, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `off-beat hi-hats don't double a tempo near 120`() {
|
||||
assertEquals(100.0, Tempo.of(beats(100.0, hats = true), rate)!!, 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slow tempo may come out doubled, which the player counts as fitting`() {
|
||||
val found = Tempo.of(beats(60.0), rate)!!
|
||||
val fits = listOf(60.0, 120.0).any { kotlin.math.abs(found - it) <= 1.0 }
|
||||
assertEquals("$found", true, fits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silence, noise and too short a piece have none`() {
|
||||
assertNull(Tempo.of(FloatArray(rate * 30), rate))
|
||||
val random = Random(3)
|
||||
assertNull(Tempo.of(FloatArray(rate * 30) { random.nextFloat() - 0.5f }, rate))
|
||||
assertNull(Tempo.of(beats(128.0, seconds = 5), rate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another sample rate gives the same tempo`() {
|
||||
val at44 = 44_100
|
||||
val beat = at44 * 60 / 128.0
|
||||
val samples = FloatArray(at44 * 20) { i ->
|
||||
val since = (i % beat) / at44
|
||||
(exp(-since * 30) * sin(2 * PI * 60 * since)).toFloat()
|
||||
}
|
||||
assertEquals(128.0, Tempo.of(samples, at44)!!, 1.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -494,5 +494,12 @@
|
|||
"musicPlaylistsHint": "Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.",
|
||||
"musicNoPlaylists": "Auf diesem Server gibt es noch keine Musik-Playlists.",
|
||||
"musicRetry": "Erneut versuchen",
|
||||
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}"
|
||||
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}",
|
||||
"musicKept": "Auf diesem Telefon",
|
||||
"musicKeptWaiting": "{done} von {total} Titeln: der Rest wartet auf WLAN",
|
||||
"musicKeptLoading": "Lädt: {done} von {total} Titeln",
|
||||
"musicKeptDone": "{count} Titel, {size} MB, {tempo} mit Tempo",
|
||||
"musicKeptDelete": "Löschen",
|
||||
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
|
||||
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -621,5 +621,15 @@
|
|||
"musicNoPlaylists": "There are no music playlists on this server yet.",
|
||||
"musicRetry": "Try again",
|
||||
"musicTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@musicTracks": {"placeholders": {"count": {"type": "int"}}}
|
||||
"@musicTracks": {"placeholders": {"count": {"type": "int"}}},
|
||||
"musicKept": "On this phone",
|
||||
"musicKeptWaiting": "{done} of {total} tracks: the rest waits for Wi-Fi",
|
||||
"@musicKeptWaiting": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"musicKeptLoading": "Downloading: {done} of {total} tracks",
|
||||
"@musicKeptLoading": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"musicKeptDone": "{count} tracks, {size} MB, {tempo} with a tempo",
|
||||
"@musicKeptDone": {"placeholders": {"count": {"type": "int"}, "size": {"type": "String", "example": "230.4"}, "tempo": {"type": "int"}}},
|
||||
"musicKeptDelete": "Delete",
|
||||
"musicKeptDeleteTitle": "Delete the music on this phone?",
|
||||
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2991,6 +2991,48 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||
String musicTracks(int count);
|
||||
|
||||
/// No description provided for @musicKept.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On this phone'**
|
||||
String get musicKept;
|
||||
|
||||
/// No description provided for @musicKeptWaiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{done} of {total} tracks: the rest waits for Wi-Fi'**
|
||||
String musicKeptWaiting(int done, int total);
|
||||
|
||||
/// No description provided for @musicKeptLoading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading: {done} of {total} tracks'**
|
||||
String musicKeptLoading(int done, int total);
|
||||
|
||||
/// No description provided for @musicKeptDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks, {size} MB, {tempo} with a tempo'**
|
||||
String musicKeptDone(int count, String size, int tempo);
|
||||
|
||||
/// No description provided for @musicKeptDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get musicKeptDelete;
|
||||
|
||||
/// No description provided for @musicKeptDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete the music on this phone?'**
|
||||
String get musicKeptDeleteTitle;
|
||||
|
||||
/// No description provided for @musicKeptDeleteText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
|
||||
String get musicKeptDeleteText;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1781,4 +1781,32 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKept => 'Auf diesem Telefon';
|
||||
|
||||
@override
|
||||
String musicKeptWaiting(int done, int total) {
|
||||
return '$done von $total Titeln: der Rest wartet auf WLAN';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptLoading(int done, int total) {
|
||||
return 'Lädt: $done von $total Titeln';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptDone(int count, String size, int tempo) {
|
||||
return '$count Titel, $size MB, $tempo mit Tempo';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKeptDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteTitle => 'Die Musik auf diesem Telefon löschen?';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteText =>
|
||||
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1769,6 +1769,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKept => 'On this phone';
|
||||
|
||||
@override
|
||||
String musicKeptWaiting(int done, int total) {
|
||||
return '$done of $total tracks: the rest waits for Wi-Fi';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptLoading(int done, int total) {
|
||||
return 'Downloading: $done of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String musicKeptDone(int count, String size, int tempo) {
|
||||
return '$count tracks, $size MB, $tempo with a tempo';
|
||||
}
|
||||
|
||||
@override
|
||||
String get musicKeptDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteTitle => 'Delete the music on this phone?';
|
||||
|
||||
@override
|
||||
String get musicKeptDeleteText =>
|
||||
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
||||
|
|
@ -110,6 +112,10 @@ class TrainPerson(
|
|||
/// Their music from Jellyfin on this phone (D47); null where none plays
|
||||
/// (iOS for now, the desktop).
|
||||
final MusicLibrary? music,
|
||||
|
||||
/// What this phone keeps of their marked playlists (D47); null where
|
||||
/// [music] is.
|
||||
final MusicDownloads? keptMusic,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// Workouts deleted, to be undone for a moment (D24); dropped with the
|
||||
|
|
@ -133,6 +139,7 @@ class TrainPerson(
|
|||
await routeDeletions.dispose();
|
||||
await offlineMaps?.dispose();
|
||||
await health?.dispose();
|
||||
await keptMusic?.dispose();
|
||||
music?.dispose();
|
||||
await loop?.dispose();
|
||||
await _onDispose?.call();
|
||||
|
|
@ -408,6 +415,25 @@ Future<TrainPerson> _openPersonData(
|
|||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
relay: ServerpodFirmwareRelayGateway(client),
|
||||
)..start());
|
||||
// Jellyfin's account and the marked playlists, on this phone alone; their
|
||||
// tracks kept up as the app opens and resumes (D47).
|
||||
final music = Platform.isAndroid
|
||||
? MusicLibrary(
|
||||
MusicStore(File(p.join(folder.path, 'music.json'))),
|
||||
SecureJellyfinTokenStore(person.authUserId),
|
||||
version: appVersion,
|
||||
)
|
||||
: null;
|
||||
final keptMusic = music == null
|
||||
? null
|
||||
: (MusicDownloads(
|
||||
music,
|
||||
Directory(p.join(folder.path, 'music')),
|
||||
AndroidMusicPlatform(),
|
||||
)..open());
|
||||
final resumed = keptMusic == null
|
||||
? null
|
||||
: AppLifecycleListener(onResume: () => unawaited(keptMusic.sync()));
|
||||
return TrainPerson(
|
||||
person,
|
||||
workouts: workouts,
|
||||
|
|
@ -430,15 +456,10 @@ Future<TrainPerson> _openPersonData(
|
|||
: null,
|
||||
health: health,
|
||||
loop: loop,
|
||||
// Jellyfin's account and the marked playlists, on this phone alone.
|
||||
music: Platform.isAndroid
|
||||
? MusicLibrary(
|
||||
MusicStore(File(p.join(folder.path, 'music.json'))),
|
||||
SecureJellyfinTokenStore(person.authUserId),
|
||||
version: appVersion,
|
||||
)
|
||||
: null,
|
||||
music: music,
|
||||
keptMusic: keptMusic,
|
||||
onDispose: () async {
|
||||
resumed?.dispose();
|
||||
try {
|
||||
await workouts.dispose();
|
||||
await profile.dispose();
|
||||
|
|
|
|||
|
|
@ -250,6 +250,9 @@ class MusicCubit(
|
|||
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
||||
_library.mark(playlist, on: on);
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _library.unmarkAll();
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _library.signOut();
|
||||
if (!isClosed) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Why Jellyfin couldn't be asked (ADR 0030, D47).
|
||||
|
|
@ -223,6 +224,91 @@ class JellyfinClient(
|
|||
];
|
||||
}
|
||||
|
||||
/// The tracks of the playlist [playlist], in its order; what isn't
|
||||
/// audio left out.
|
||||
Future<List<MusicTrack>> tracks(
|
||||
Uri address,
|
||||
JellyfinSession session,
|
||||
String playlist,
|
||||
) async {
|
||||
final answer = await _send(
|
||||
'GET',
|
||||
address,
|
||||
'/Playlists/$playlist/Items',
|
||||
token: session.token,
|
||||
query: {'userId': session.userId},
|
||||
);
|
||||
return [
|
||||
if (answer case {'Items': final List<Object?> items})
|
||||
for (final item in items)
|
||||
if (item case {
|
||||
'Id': final String id,
|
||||
'Name': final String name,
|
||||
'Type': 'Audio',
|
||||
})
|
||||
MusicTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
artist: switch (item) {
|
||||
{'AlbumArtist': final String artist} => artist,
|
||||
{'Artists': [final String artist, ...]} => artist,
|
||||
_ => null,
|
||||
},
|
||||
album: item['Album'] as String?,
|
||||
// Jellyfin counts in ticks of 100 ns.
|
||||
length: switch (item['RunTimeTicks']) {
|
||||
final int ticks => Duration(microseconds: ticks ~/ 10),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Downloads the track [id] as Jellyfin keeps it into [file], through a
|
||||
/// part file renamed once whole; its size.
|
||||
Future<int> download(
|
||||
Uri address,
|
||||
JellyfinSession session,
|
||||
String id,
|
||||
File file,
|
||||
) async {
|
||||
final request = http.Request(
|
||||
'GET',
|
||||
address.replace(
|
||||
path: '${address.path}/Audio/$id/stream',
|
||||
queryParameters: {'static': 'true'},
|
||||
),
|
||||
)..headers['authorization'] = _authorization(session.token);
|
||||
final part = File('${file.path}.part');
|
||||
try {
|
||||
final response = await _http.send(request).timeout(_timeout);
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw const JellyfinException(JellyfinFailure.refused);
|
||||
}
|
||||
if (response.statusCode >= 300) {
|
||||
throw const JellyfinException(JellyfinFailure.failed);
|
||||
}
|
||||
file.parent.createSync(recursive: true);
|
||||
final sink = part.openWrite();
|
||||
try {
|
||||
// A connection that stalls for the timeout gives up.
|
||||
await sink.addStream(response.stream.timeout(_timeout));
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
part.renameSync(file.path);
|
||||
return file.lengthSync();
|
||||
} on Object catch (error) {
|
||||
if (part.existsSync()) {
|
||||
part.deleteSync();
|
||||
}
|
||||
if (_unreachable(error)) {
|
||||
throw const JellyfinException(JellyfinFailure.unreachable);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends [session]'s token on the server; what fails is left, as the
|
||||
/// device forgets the token anyway.
|
||||
Future<void> signOut(Uri address, JellyfinSession session) async {
|
||||
|
|
@ -233,6 +319,12 @@ class JellyfinClient(
|
|||
}
|
||||
}
|
||||
|
||||
static bool _unreachable(Object error) =>
|
||||
error is http.ClientException ||
|
||||
error is SocketException ||
|
||||
error is TimeoutException ||
|
||||
error is HandshakeException;
|
||||
|
||||
JellyfinSignIn _signedIn(Object? answer) {
|
||||
if (answer case {
|
||||
'AccessToken': final String token,
|
||||
|
|
@ -277,10 +369,7 @@ class JellyfinClient(
|
|||
await _http.send(request).timeout(_timeout),
|
||||
).timeout(_timeout);
|
||||
} on Object catch (error) {
|
||||
if (error is http.ClientException ||
|
||||
error is SocketException ||
|
||||
error is TimeoutException ||
|
||||
error is HandshakeException) {
|
||||
if (_unreachable(error)) {
|
||||
throw const JellyfinException(JellyfinFailure.unreachable);
|
||||
}
|
||||
rethrow;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// What the phone keeps of the marked playlists (ADR 0030, D47).
|
||||
final class const KeptMusic({
|
||||
/// Every track of the marked playlists, by its ID, downloaded or not.
|
||||
final Map<String, MusicTrack> tracks = const {},
|
||||
|
||||
/// Whether a download is under way.
|
||||
final bool loading = false,
|
||||
|
||||
/// Whether downloads wait for an unmetered network.
|
||||
final bool waiting = false,
|
||||
}) with Equatable {
|
||||
int get downloaded => tracks.values.where((track) => track.downloaded).length;
|
||||
|
||||
int get bytes =>
|
||||
tracks.values.fold(0, (sum, track) => sum + (track.bytes ?? 0));
|
||||
|
||||
/// The tracks with a tempo, ready to be chosen by it.
|
||||
int get withTempo => tracks.values
|
||||
.where((track) => track.downloaded && track.tempo != null)
|
||||
.length;
|
||||
|
||||
KeptMusic copyWith({
|
||||
Map<String, MusicTrack>? tracks,
|
||||
bool? loading,
|
||||
bool? waiting,
|
||||
}) => KeptMusic(
|
||||
tracks: tracks ?? this.tracks,
|
||||
loading: loading ?? this.loading,
|
||||
waiting: waiting ?? this.waiting,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tracks, loading, waiting];
|
||||
}
|
||||
|
||||
/// Keeps the marked playlists' tracks on this phone (D47): their list read
|
||||
/// from Jellyfin, each track downloaded as Jellyfin keeps it on unmetered
|
||||
/// networks only, its tempo worked out once ([MusicPlatform.tempo]); the
|
||||
/// tracks no marked playlist holds any more deleted, offline too. Files in
|
||||
/// [_folder] by their IDs, the list in its `tracks.json`.
|
||||
class MusicDownloads(
|
||||
final MusicLibrary _library,
|
||||
final Directory _folder,
|
||||
final MusicPlatform _platform,
|
||||
) {
|
||||
late final ValueNotifier<KeptMusic> kept = ValueNotifier(
|
||||
KeptMusic(tracks: _read()),
|
||||
);
|
||||
|
||||
late final _runner = CoalescingRunner(
|
||||
_sync,
|
||||
onError: (error, _) =>
|
||||
log('Music not kept: ${error.runtimeType}', name: 'Music'),
|
||||
);
|
||||
|
||||
var _closed = false;
|
||||
|
||||
File get _list => File(p.join(_folder.path, 'tracks.json'));
|
||||
|
||||
/// The file of the track [id]; null where the ID isn't one Train keeps.
|
||||
File? fileOf(String id) => RegExp(r'^[A-Za-z0-9-]+$').hasMatch(id)
|
||||
? File(p.join(_folder.path, id))
|
||||
: null;
|
||||
|
||||
/// Follows the marks and keeps the music up with them; [sync] again
|
||||
/// whenever a network may have come (the app resumed).
|
||||
void open() {
|
||||
_library.settings.addListener(_marksChanged);
|
||||
unawaited(sync());
|
||||
}
|
||||
|
||||
/// Keeps the music up with the marked playlists now.
|
||||
Future<void> sync() => _runner.run();
|
||||
|
||||
Future<void> dispose() async {
|
||||
_closed = true;
|
||||
_library.settings.removeListener(_marksChanged);
|
||||
kept.dispose();
|
||||
}
|
||||
|
||||
var _marked = <String>{};
|
||||
|
||||
void _marksChanged() {
|
||||
final marked = {..._library.settings.value.playlists.keys};
|
||||
if (!setEquals(marked, _marked)) {
|
||||
unawaited(sync());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sync() async {
|
||||
final settings = _library.settings.value;
|
||||
_marked = {...settings.playlists.keys};
|
||||
// What no marked playlist holds goes first, with or without a network.
|
||||
_keepOnly((track) => track.playlists.any(_marked.contains));
|
||||
if (!settings.signedIn || _marked.isEmpty || _closed) {
|
||||
return;
|
||||
}
|
||||
if (!await _platform.unmetered()) {
|
||||
_set(kept.value.copyWith(waiting: true));
|
||||
return;
|
||||
}
|
||||
_set(kept.value.copyWith(waiting: false, loading: true));
|
||||
try {
|
||||
final listed = <String, MusicTrack>{};
|
||||
final unread = <String>{};
|
||||
for (final playlist in _marked) {
|
||||
try {
|
||||
for (final track in await _library.tracks(playlist)) {
|
||||
final known = listed[track.id] ?? kept.value.tracks[track.id];
|
||||
listed[track.id] = (known ?? track).copyWith(
|
||||
name: track.name,
|
||||
artist: () => track.artist,
|
||||
album: () => track.album,
|
||||
length: () => track.length,
|
||||
playlists: {
|
||||
if (listed[track.id] case final seen?) ...seen.playlists,
|
||||
playlist,
|
||||
},
|
||||
);
|
||||
}
|
||||
} on JellyfinException catch (error) {
|
||||
if (error.failure == JellyfinFailure.unreachable ||
|
||||
error.failure == JellyfinFailure.refused) {
|
||||
return;
|
||||
}
|
||||
// A playlist that can't be read keeps what it had.
|
||||
unread.add(playlist);
|
||||
}
|
||||
}
|
||||
for (final track in kept.value.tracks.values) {
|
||||
final holding = track.playlists.where(unread.contains);
|
||||
if (holding.isNotEmpty) {
|
||||
listed[track.id] = switch (listed[track.id]) {
|
||||
final read? => read.copyWith(
|
||||
playlists: {...read.playlists, ...holding},
|
||||
),
|
||||
null => track,
|
||||
};
|
||||
}
|
||||
}
|
||||
// The files of what the playlists no longer hold go before the list
|
||||
// forgets them.
|
||||
_keepOnly((track) => listed.containsKey(track.id));
|
||||
_set(kept.value.copyWith(tracks: listed));
|
||||
await _download();
|
||||
} finally {
|
||||
if (!_closed) {
|
||||
_set(kept.value.copyWith(loading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
for (final id in [...kept.value.tracks.keys]) {
|
||||
final track = kept.value.tracks[id];
|
||||
final file = fileOf(id);
|
||||
if (track == null || file == null || _closed) {
|
||||
continue;
|
||||
}
|
||||
if (!track.downloaded) {
|
||||
if (!await _platform.unmetered()) {
|
||||
_set(kept.value.copyWith(waiting: true));
|
||||
return;
|
||||
}
|
||||
final int bytes;
|
||||
try {
|
||||
bytes = await _library.download(id, file);
|
||||
} on JellyfinException catch (error) {
|
||||
if (error.failure == JellyfinFailure.failed) {
|
||||
// This track alone: the next sync tries it again.
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_update(id, (track) => track.copyWith(bytes: () => bytes));
|
||||
}
|
||||
if (!track.analysed) {
|
||||
double? tempo;
|
||||
try {
|
||||
tempo = await _platform.tempo(file.path);
|
||||
} on PlatformException catch (error) {
|
||||
log('Tempo not worked out: ${error.code}', name: 'Music');
|
||||
}
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_update(
|
||||
id,
|
||||
(track) => track.copyWith(tempo: () => tempo, analysed: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _update(String id, MusicTrack Function(MusicTrack) change) {
|
||||
if (kept.value.tracks[id] case final track?) {
|
||||
_set(
|
||||
kept.value.copyWith(tracks: {...kept.value.tracks, id: change(track)}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the tracks [keep] doesn't keep, and their files.
|
||||
void _keepOnly(bool Function(MusicTrack) keep) {
|
||||
final tracks = kept.value.tracks;
|
||||
final gone = [
|
||||
for (final track in tracks.values)
|
||||
if (!keep(track)) track.id,
|
||||
];
|
||||
if (gone.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final id in gone) {
|
||||
try {
|
||||
fileOf(id)?.deleteSync();
|
||||
} on PathNotFoundException {
|
||||
// Never downloaded.
|
||||
}
|
||||
}
|
||||
_set(
|
||||
kept.value.copyWith(
|
||||
tracks: {
|
||||
for (final MapEntry(:key, :value) in tracks.entries)
|
||||
if (!gone.contains(key)) key: value,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, MusicTrack> _read() {
|
||||
try {
|
||||
final json = jsonDecode(_list.readAsStringSync());
|
||||
return {
|
||||
if (json case final List<Object?> tracks)
|
||||
for (final track in tracks.map(MusicTrack.fromJson).nonNulls)
|
||||
track.id: track,
|
||||
};
|
||||
} on PathNotFoundException {
|
||||
return const {};
|
||||
} on Object catch (error) {
|
||||
log('Kept music not read: ${error.runtimeType}', name: 'Music');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
void _set(KeptMusic value) {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
if (!mapEquals(value.tracks, kept.value.tracks)) {
|
||||
_folder.createSync(recursive: true);
|
||||
File('${_list.path}.part')
|
||||
..writeAsStringSync(
|
||||
jsonEncode([for (final track in value.tracks.values) track.toJson()]),
|
||||
)
|
||||
..renameSync(_list.path);
|
||||
}
|
||||
kept.value = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// The person's music on Jellyfin, on this phone (ADR 0030, D47): the
|
||||
|
|
@ -60,14 +63,29 @@ class MusicLibrary(
|
|||
|
||||
/// The person's playlists of music; a token Jellyfin took back signs the
|
||||
/// person out here too.
|
||||
Future<List<JellyfinPlaylist>> playlists() async {
|
||||
Future<List<JellyfinPlaylist>> playlists() => _asking(jellyfin.playlists);
|
||||
|
||||
/// The tracks of the playlist [playlist].
|
||||
Future<List<MusicTrack>> tracks(String playlist) => _asking(
|
||||
(address, session) => jellyfin.tracks(address, session, playlist),
|
||||
);
|
||||
|
||||
/// Downloads the track [id] into [file]; its size.
|
||||
Future<int> download(String id, File file) => _asking(
|
||||
(address, session) => jellyfin.download(address, session, id, file),
|
||||
);
|
||||
|
||||
/// What [ask] answers as the person signed in.
|
||||
Future<T> _asking<T>(
|
||||
Future<T> Function(Uri address, JellyfinSession session) ask,
|
||||
) async {
|
||||
final current = settings.value;
|
||||
final token = await _tokens.read();
|
||||
if (!current.signedIn || token == null) {
|
||||
throw const JellyfinException(JellyfinFailure.refused);
|
||||
}
|
||||
try {
|
||||
return await jellyfin.playlists(current.address!, (
|
||||
return await ask(current.address!, (
|
||||
token: token,
|
||||
userId: current.userId!,
|
||||
));
|
||||
|
|
@ -91,6 +109,9 @@ class MusicLibrary(
|
|||
_set(settings.value.copyWith(playlists: playlists));
|
||||
}
|
||||
|
||||
/// Unmarks every playlist, which deletes their music on this phone.
|
||||
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
|
||||
|
||||
/// Signs out: the token ended on the server where it can be, forgotten
|
||||
/// here, and the marked playlists with it.
|
||||
Future<void> signOut() async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The phone's side of music (ADR 0030, D47).
|
||||
abstract interface class MusicPlatform() {
|
||||
/// Whether the network is unmetered, as downloads wait for one.
|
||||
Future<bool> unmetered();
|
||||
|
||||
/// The beats per minute of the track in the file at [path]; null where
|
||||
/// neither its tag nor its beat says one.
|
||||
Future<double?> tempo(String path);
|
||||
}
|
||||
|
||||
/// [MusicPlatform] on Android, over `app.halkyon.train/music`
|
||||
/// (`MusicBridge.kt`): the tag `BpmTag.kt` reads, else `Tempo.kt`'s own
|
||||
/// analysis of a minute from the track's middle.
|
||||
class AndroidMusicPlatform({
|
||||
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
|
||||
}) implements MusicPlatform {
|
||||
@override
|
||||
Future<bool> unmetered() async =>
|
||||
await _channel.invokeMethod<bool>('unmetered') ?? false;
|
||||
|
||||
@override
|
||||
Future<double?> tempo(String path) =>
|
||||
_channel.invokeMethod<double>('tempo', {'path': path});
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
|
||||
/// as this phone keeps it.
|
||||
final class const MusicTrack({
|
||||
required final String id,
|
||||
required final String name,
|
||||
final String? artist,
|
||||
final String? album,
|
||||
|
||||
/// Its length as Jellyfin gives it.
|
||||
final Duration? length,
|
||||
|
||||
/// The marked playlists holding it, by their IDs.
|
||||
final Set<String> playlists = const {},
|
||||
|
||||
/// Its file's size once downloaded; null before.
|
||||
final int? bytes,
|
||||
|
||||
/// Its beats per minute; null where none was found or before it was
|
||||
/// worked out ([analysed]).
|
||||
final double? tempo,
|
||||
|
||||
/// Whether its tempo was worked out (found or not).
|
||||
final bool analysed = false,
|
||||
}) with Equatable {
|
||||
bool get downloaded => bytes != null;
|
||||
|
||||
MusicTrack copyWith({
|
||||
String? name,
|
||||
String? Function()? artist,
|
||||
String? Function()? album,
|
||||
Duration? Function()? length,
|
||||
Set<String>? playlists,
|
||||
int? Function()? bytes,
|
||||
double? Function()? tempo,
|
||||
bool? analysed,
|
||||
}) => MusicTrack(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
artist: artist == null ? this.artist : artist(),
|
||||
album: album == null ? this.album : album(),
|
||||
length: length == null ? this.length : length(),
|
||||
playlists: playlists ?? this.playlists,
|
||||
bytes: bytes == null ? this.bytes : bytes(),
|
||||
tempo: tempo == null ? this.tempo : tempo(),
|
||||
analysed: analysed ?? this.analysed,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'artist': artist,
|
||||
'album': album,
|
||||
'ms': length?.inMilliseconds,
|
||||
'playlists': [...playlists],
|
||||
'bytes': bytes,
|
||||
'tempo': tempo,
|
||||
'analysed': analysed,
|
||||
};
|
||||
|
||||
static MusicTrack? fromJson(Object? json) => switch (json) {
|
||||
{'id': final String id, 'name': final String name} &&
|
||||
final Map<String, Object?> json =>
|
||||
MusicTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
artist: json['artist'] as String?,
|
||||
album: json['album'] as String?,
|
||||
length: switch (json['ms']) {
|
||||
final int ms => Duration(milliseconds: ms),
|
||||
_ => null,
|
||||
},
|
||||
playlists: {
|
||||
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
|
||||
},
|
||||
bytes: json['bytes'] as int?,
|
||||
tempo: (json['tempo'] as num?)?.toDouble(),
|
||||
analysed: json['analysed'] == true,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
artist,
|
||||
album,
|
||||
length,
|
||||
playlists,
|
||||
bytes,
|
||||
tempo,
|
||||
analysed,
|
||||
];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue