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:
Tizian 「ティツィアーン」 2026-10-29 12:48:53 +01:00
commit 7f18f001f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
25 changed files with 1676 additions and 24 deletions

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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`).

View file

@ -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();

View file

@ -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) {

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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});
}

View file

@ -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