Let English listening pause and change speed and volume.

The playback controls apply on the Mac, in the GTK app, and on Android, and a change mid-recording continues from the current place.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-07 14:25:05 +02:00
commit 29bd0e63c7
5 changed files with 577 additions and 73 deletions

View file

@ -38,10 +38,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
@ -1395,6 +1397,11 @@ fun EngExScreen(vm: AppViewModel, year: Int, id: Int) {
var essay by remember(year, id) { mutableStateOf("") }
var played by remember(year, id) { mutableStateOf(false) }
var showScript by remember(year, id) { mutableStateOf(false) }
var phase by remember(year, id) { mutableIntStateOf(0) }
var speed by remember(year, id) { mutableStateOf(0.9f) }
var volume by remember(year, id) { mutableStateOf(1f) }
var cursor by remember(year, id) { mutableIntStateOf(0) }
var utterance by remember(year, id) { mutableStateOf("") }
var revealed by remember(year, id) { mutableStateOf(false) }
var showModel by remember(year, id) { mutableStateOf(false) }
var fb by remember(year, id) { mutableStateOf("" to "") }
@ -1455,31 +1462,89 @@ fun EngExScreen(vm: AppViewModel, year: Int, id: Int) {
EngChoices(readQ, readPicks, revealed) { index, option -> readPicks[index] = option }
}
if (listening != null) {
fun speakFrom(index: Int) {
val engine = tts
if (engine == null) {
showScript = true
phase = 0
return
}
val start = index.coerceIn(0, listening.length)
val id = "en-${System.nanoTime()}"
utterance = id
engine.language = Locale.UK
engine.setSpeechRate(speed)
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onRangeStart(utteranceId: String?, rangeStart: Int, rangeEnd: Int, frame: Int) {
if (utteranceId == id) cursor = start + rangeStart
}
override fun onDone(utteranceId: String?) {
if (utteranceId != utterance) return
Handler(Looper.getMainLooper()).post {
phase = 0
cursor = 0
showScript = true
}
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
if (utteranceId != utterance) return
Handler(Looper.getMainLooper()).post { showScript = true; phase = 0 }
}
})
val params = Bundle().apply {
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.coerceIn(0f, 1f))
}
val rest = listening.substring(start).ifEmpty { listening }
engine.speak(rest, TextToSpeech.QUEUE_FLUSH, params, id)
phase = 1
}
Text(vm.tr("en_sec_listen"), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
Text(vm.tr("en_listen_note"), color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = {
played = true
showScript = false
val engine = tts
if (engine == null) {
showScript = true
} else {
engine.language = Locale.UK
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
Handler(Looper.getMainLooper()).post { showScript = true }
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
Handler(Looper.getMainLooper()).post { showScript = true }
}
})
engine.speak(listening, TextToSpeech.QUEUE_FLUSH, null, "en-lesson")
val wait = (listening.split(" ").size * 420L).coerceAtLeast(2500L)
Handler(Looper.getMainLooper()).postDelayed({ showScript = true }, wait)
when (phase) {
1 -> {
utterance = ""
tts?.stop()
phase = 2
}
2 -> speakFrom(cursor)
else -> {
cursor = 0
speakFrom(0)
}
}
}) { Text(vm.tr("en_play")) }
}) {
Text(when (phase) {
1 -> vm.tr("en_pause")
2 -> vm.tr("en_resume")
else -> vm.tr("en_play")
})
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(vm.tr("en_speed"), modifier = Modifier.width(96.dp))
Slider(
value = speed,
onValueChange = { speed = it },
valueRange = 0.6f..1.5f,
onValueChangeFinished = { if (phase == 1) speakFrom(cursor) },
modifier = Modifier.weight(1f),
)
Text("%.1f×".format(speed), modifier = Modifier.width(48.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(vm.tr("en_volume"), modifier = Modifier.width(96.dp))
Slider(
value = volume,
onValueChange = { volume = it },
valueRange = 0f..1f,
onValueChangeFinished = { if (phase == 1) speakFrom(cursor) },
modifier = Modifier.weight(1f),
)
Text("${(volume * 100).toInt()} %", modifier = Modifier.width(48.dp))
}
if (showScript) Text(listening)
EngChoices(listenQ, listenPicks, revealed) { index, option -> listenPicks[index] = option }
}

View file

@ -1346,16 +1346,96 @@ struct PlotScreen: View {
}
}
private final class EnSpeaker: ObservableObject {
private let synth = AVSpeechSynthesizer()
private final class EnSpeaker: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
enum Phase { case idle, playing, paused }
func speak(_ text: String) {
if synth.isSpeaking { synth.stopSpeaking(at: .immediate) }
let utterance = AVSpeechUtterance(string: text)
private let synth = AVSpeechSynthesizer()
private var text = ""
private var base = 0
private var cursor = 0
private var restarting = false
private var settingsDirty = false
@Published var rate: Float = 0.9
@Published var volume: Float = 1
@Published var phase: Phase = .idle
var onFinish: (() -> Void)?
override init() {
super.init()
synth.delegate = self
}
func toggle(_ full: String) {
if text != full {
text = full
cursor = 0
base = 0
synth.stopSpeaking(at: .immediate)
phase = .idle
}
switch phase {
case .idle:
cursor = 0
start(from: 0)
case .playing:
synth.pauseSpeaking(at: .immediate)
phase = .paused
case .paused:
if settingsDirty || !synth.isPaused {
start(from: cursor)
} else {
synth.continueSpeaking()
phase = .playing
}
settingsDirty = false
}
}
func applySettings() {
settingsDirty = true
guard phase == .playing else { return }
restarting = true
synth.stopSpeaking(at: .immediate)
start(from: cursor)
settingsDirty = false
}
private func start(from index: Int) {
let ns = text as NSString
let start = min(max(0, index), ns.length)
base = start
let rest = ns.substring(from: start)
let utterance = AVSpeechUtterance(string: rest.isEmpty ? text : rest)
if rest.isEmpty { base = 0 }
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
?? AVSpeechSynthesisVoice(language: "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * rate
utterance.volume = volume
synth.speak(utterance)
phase = .playing
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
cursor = base + characterRange.location
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
DispatchQueue.main.async {
self.phase = .idle
self.cursor = 0
self.onFinish?()
}
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
if restarting {
restarting = false
return
}
DispatchQueue.main.async {
if self.phase == .playing { self.phase = .idle }
}
}
}
@ -1403,15 +1483,7 @@ struct EngExScreen: View {
Text(vm.tr("en_listen_note"))
.font(.subheadline)
.foregroundStyle(.secondary)
PillButton(label: vm.tr("en_play")) {
played = true
showScript = false
speaker.speak(listening)
let wait = max(2.5, Double(listening.split(separator: " ").count) * 0.42)
DispatchQueue.main.asyncAfter(deadline: .now() + wait) {
showScript = true
}
}
listenControls(listening)
if showScript {
Text(listening).textSelection(.enabled)
}
@ -1462,6 +1534,46 @@ struct EngExScreen: View {
.padding(.top, 8)
}
private func listenControls(_ text: String) -> some View {
let label: String = switch speaker.phase {
case .playing: vm.tr("en_pause")
case .paused: vm.tr("en_resume")
case .idle: vm.tr("en_play")
}
return VStack(alignment: .leading, spacing: 8) {
PillButton(label: label) {
played = true
speaker.onFinish = { showScript = true }
speaker.toggle(text)
}
listenSlider(vm.tr("en_speed"), value: Double(speaker.rate), range: 0.6...1.5, caption: String(format: "%.1f×", speaker.rate)) { next in
speaker.rate = Float(next)
}
listenSlider(vm.tr("en_volume"), value: Double(speaker.volume), range: 0...1, caption: "\(Int(speaker.volume * 100)) %") { next in
speaker.volume = Float(next)
}
}
}
private func listenSlider(_ title: String, value: Double, range: ClosedRange<Double>, caption: String, set: @escaping (Double) -> Void) -> some View {
HStack(spacing: 10) {
Text(title)
.frame(width: 88, alignment: .leading)
.foregroundStyle(.secondary)
Slider(
value: Binding(get: { value }, set: set),
in: range,
onEditingChanged: { editing in
if !editing { speaker.applySettings() }
}
)
Text(caption)
.monospacedDigit()
.frame(width: 52, alignment: .trailing)
.foregroundStyle(.secondary)
}
}
private func choiceBlock(_ questions: [J], picks: Binding<[Int]>) -> some View {
let p = vm.palette
return ForEach(questions.indices, id: \.self) { i in

View file

@ -2494,6 +2494,10 @@ def main() -> None:
' {"en_sec_quiz", "Kvíz", "Quiz"},',
' {"en_sec_test", "Jazyková část", "Language"},',
' {"en_play", "Přehrát", "Play"},',
' {"en_pause", "Pauza", "Pause"},',
' {"en_resume", "Pokračovat", "Resume"},',
' {"en_speed", "Rychlost", "Speed"},',
' {"en_volume", "Hlasitost", "Volume"},',
' {"en_listen_note", "Nejdřív si přečtěte otázky. Nahrávku přehraje hlas zařízení. Bez něj se věty ukážou postupně.", "Read the questions first. The device voice plays the recording. Without one, the sentences appear one by one."},',
' {"en_listen_first", "Nejdřív poslech přehrajte.", "Play the recording first."},',
' {"en_listen_fallback", "Hlas není dostupný, věty se ukazují postupně.", "No voice is available, so the sentences appear one by one."},',

View file

@ -2,7 +2,12 @@
#include <string.h>
#ifdef __APPLE__
#include <objc/message.h>
#include <objc/runtime.h>
#endif
#ifdef G_OS_UNIX
#include <signal.h>
#include <sys/wait.h>
#endif
@ -54,13 +59,26 @@ typedef struct {
GtkWidget *essay;
GtkWidget *script;
GtkWidget *play_note;
GtkWidget *play_btn;
gboolean played;
gboolean busy;
gboolean paused;
gboolean fallback;
gboolean restarting;
gboolean voice_dirty;
gboolean heard;
double speed;
double gain;
GPid pid;
guint timer;
guint tick_ms;
guint apply_src;
guint poll;
int tick;
gchar **sentences;
#ifdef __APPLE__
id synth;
#endif
} EnEx;
typedef struct {
@ -890,16 +908,40 @@ static void en_split_script(EnEx *ctx) {
ctx->tick = 0;
}
static void en_sync_play(EnEx *ctx) {
const char *key = "en_play";
if (!ctx->play_btn)
return;
if (ctx->paused)
key = "en_resume";
else if (ctx->busy)
key = "en_pause";
gtk_button_set_label(GTK_BUTTON(ctx->play_btn), tr(key));
}
static void en_show_script(EnEx *ctx) {
if (ctx->script && ctx->lesson->listening) {
gtk_label_set_text(GTK_LABEL(ctx->script), ctx->lesson->listening);
gtk_widget_set_visible(ctx->script, TRUE);
}
}
static void en_schedule_tick(EnEx *ctx);
static gboolean en_tick(gpointer data) {
EnEx *ctx = data;
GString *shown;
int i;
if (ctx->paused)
return G_SOURCE_CONTINUE;
if (!ctx->sentences || !ctx->sentences[ctx->tick]) {
ctx->timer = 0;
ctx->busy = FALSE;
if (ctx->script && ctx->lesson->listening)
gtk_label_set_text(GTK_LABEL(ctx->script), ctx->lesson->listening);
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
shown = g_string_new(NULL);
@ -915,11 +957,24 @@ static gboolean en_tick(gpointer data) {
if (!ctx->sentences[ctx->tick]) {
ctx->timer = 0;
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
static void en_schedule_tick(EnEx *ctx) {
guint ms = ctx->tick_ms;
if (ctx->timer)
g_source_remove(ctx->timer);
if (ms < 400)
ms = 400;
ctx->timer = g_timeout_add(ms, en_tick, ctx);
}
static void en_start_fallback(EnEx *ctx) {
if (ctx->fallback)
return;
@ -933,34 +988,40 @@ static void en_start_fallback(EnEx *ctx) {
if (!ctx->sentences)
en_split_script(ctx);
ctx->tick = 0;
if (ctx->timer)
g_source_remove(ctx->timer);
ctx->timer = g_timeout_add(2200, en_tick, ctx);
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
en_sync_play(ctx);
en_tick(ctx);
}
static gboolean en_launch(EnEx *ctx);
static void en_voice_done(GPid pid, gint status, gpointer data) {
EnEx *ctx = data;
gboolean ok = FALSE;
gboolean restart = ctx->restarting;
g_spawn_close_pid(pid);
if (ctx->pid == pid)
ctx->pid = 0;
if (restart) {
ctx->restarting = FALSE;
en_launch(ctx);
return;
}
#ifdef G_OS_UNIX
ok = WIFEXITED(status) && WEXITSTATUS(status) == 0;
#else
ok = status == 0;
#endif
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
if (!ok) {
ctx->busy = FALSE;
en_start_fallback(ctx);
return;
}
ctx->busy = FALSE;
if (ctx->script && ctx->lesson->listening) {
gtk_label_set_text(GTK_LABEL(ctx->script), ctx->lesson->listening);
gtk_widget_set_visible(ctx->script, TRUE);
}
en_show_script(ctx);
}
static gboolean en_spawn(char **argv, GPid *pid) {
@ -977,47 +1038,297 @@ static gboolean en_spawn(char **argv, GPid *pid) {
return TRUE;
}
static void en_play_clicked(GtkButton *button, gpointer data) {
EnEx *ctx = data;
char *text;
#ifdef __APPLE__
static id en_ns(const char *s) {
return ((id (*)(Class, SEL, const char *))objc_msgSend)(
objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), s);
}
(void)button;
if (!ctx->lesson->listening || ctx->busy)
static void en_mac_levels(EnEx *ctx) {
float rate = (float)(180.0 * (ctx->speed > 0.2 ? ctx->speed : 0.9));
float vol = (float)(ctx->gain < 0 ? 0 : (ctx->gain > 1 ? 1 : ctx->gain));
((void (*)(id, SEL, float))objc_msgSend)(
ctx->synth, sel_registerName("setRate:"), rate);
((void (*)(id, SEL, float))objc_msgSend)(
ctx->synth, sel_registerName("setVolume:"), vol);
}
static gboolean en_mac_ensure(EnEx *ctx) {
static const char *voices[] = {
"com.apple.voice.compact.en-GB.Daniel",
"com.apple.speech.synthesis.voice.daniel",
"com.apple.voice.compact.en-US.Samantha",
"com.apple.speech.synthesis.voice.samantha",
NULL
};
Class cls;
id obj;
if (ctx->synth)
return TRUE;
cls = objc_getClass("NSSpeechSynthesizer");
if (!cls)
return FALSE;
obj = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_registerName("alloc"));
obj = ((id (*)(id, SEL))objc_msgSend)(obj, sel_registerName("init"));
if (!obj)
return FALSE;
ctx->synth = obj;
for (int i = 0; voices[i]; i++) {
if (((BOOL (*)(id, SEL, id))objc_msgSend)(
obj, sel_registerName("setVoice:"), en_ns(voices[i])))
break;
}
return TRUE;
}
static gboolean en_mac_speaking(EnEx *ctx) {
if (!ctx->synth)
return FALSE;
return ((BOOL (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("isSpeaking"));
}
static void en_mac_stop(EnEx *ctx) {
if (ctx->synth)
((void (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("stopSpeaking"));
}
static gboolean en_mac_start(EnEx *ctx) {
if (!en_mac_ensure(ctx))
return FALSE;
en_mac_levels(ctx);
ctx->heard = FALSE;
return ((BOOL (*)(id, SEL, id))objc_msgSend)(
ctx->synth, sel_registerName("startSpeakingString:"),
en_ns(ctx->lesson->listening));
}
static void en_mac_pause(EnEx *ctx) {
((BOOL (*)(id, SEL, long))objc_msgSend)(
ctx->synth, sel_registerName("pauseSpeakingAtBoundary:"), 1L);
}
static void en_mac_resume(EnEx *ctx) {
if (ctx->voice_dirty) {
ctx->voice_dirty = FALSE;
en_mac_stop(ctx);
en_mac_start(ctx);
return;
}
((BOOL (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("continueSpeaking"));
}
static gboolean en_poll_speech(gpointer data) {
EnEx *ctx = data;
if (ctx->paused || ctx->restarting)
return G_SOURCE_CONTINUE;
if (en_mac_speaking(ctx)) {
ctx->heard = TRUE;
return G_SOURCE_CONTINUE;
}
if (!ctx->heard)
return G_SOURCE_CONTINUE;
ctx->poll = 0;
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
#endif
static gboolean en_launch(EnEx *ctx) {
char *text = (char *)ctx->lesson->listening;
double speed = ctx->speed > 0.2 ? ctx->speed : 0.9;
char rate[16];
char amp[16];
ctx->played = TRUE;
ctx->busy = TRUE;
text = (char *)ctx->lesson->listening;
ctx->paused = FALSE;
ctx->voice_dirty = FALSE;
#ifdef __APPLE__
{
char *tries[][8] = {
{"say", "-v", "Samantha", "-r", "150", text, NULL},
{"say", "-v", "Daniel", "-r", "150", text, NULL},
{"say", "-r", "150", text, NULL},
};
for (int i = 0; i < 3; i++) {
if (en_spawn(tries[i], &ctx->pid)) {
g_child_watch_add(ctx->pid, en_voice_done, ctx);
return;
}
}
}
#else
{
char *a[] = {"espeak-ng", "-v", "en", "-s", "140", text, NULL};
char *b[] = {"espeak", "-v", "en", "-s", "140", text, NULL};
char *c[] = {"spd-say", "-l", "en", text, NULL};
char **tries[] = {a, b, c};
for (int i = 0; i < 3; i++) {
if (en_spawn(tries[i], &ctx->pid)) {
g_child_watch_add(ctx->pid, en_voice_done, ctx);
return;
}
}
if (en_mac_start(ctx)) {
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return TRUE;
}
#endif
g_snprintf(rate, sizeof rate, "%d", (int)(160.0 * speed));
g_snprintf(amp, sizeof amp, "%d", (int)(ctx->gain * 200.0));
{
char *a[] = {"espeak-ng", "-v", "en", "-s", rate, "-a", amp, text, NULL};
char *b[] = {"espeak", "-v", "en", "-s", rate, "-a", amp, text, NULL};
#ifdef __APPLE__
char *c[] = {"say", "-r", rate, text, NULL};
char **tries[] = {c};
int n = 1;
#else
char *c[] = {"spd-say", "-l", "en", "-r", rate, text, NULL};
char **tries[] = {a, b, c};
int n = 3;
#endif
(void)a;
(void)b;
for (int i = 0; i < n; i++) {
if (en_spawn(tries[i], &ctx->pid)) {
g_child_watch_add(ctx->pid, en_voice_done, ctx);
en_sync_play(ctx);
return TRUE;
}
}
}
ctx->busy = FALSE;
en_start_fallback(ctx);
return FALSE;
}
static void en_pause_voice(EnEx *ctx) {
ctx->paused = TRUE;
#ifdef __APPLE__
if (ctx->synth && en_mac_speaking(ctx)) {
en_mac_pause(ctx);
en_sync_play(ctx);
return;
}
#endif
#ifdef G_OS_UNIX
if (ctx->pid) {
kill((pid_t)ctx->pid, SIGSTOP);
en_sync_play(ctx);
return;
}
#endif
en_sync_play(ctx);
}
static void en_resume_voice(EnEx *ctx) {
ctx->paused = FALSE;
#ifdef __APPLE__
if (ctx->synth) {
en_mac_resume(ctx);
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return;
}
#endif
if (ctx->voice_dirty) {
ctx->voice_dirty = FALSE;
if (ctx->fallback) {
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
} else if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
kill((pid_t)ctx->pid, SIGKILL);
#endif
}
en_sync_play(ctx);
return;
}
#ifdef G_OS_UNIX
if (ctx->pid) {
kill((pid_t)ctx->pid, SIGCONT);
en_sync_play(ctx);
return;
}
#endif
en_sync_play(ctx);
}
static void en_restart_voice(EnEx *ctx) {
ctx->voice_dirty = FALSE;
ctx->paused = FALSE;
#ifdef __APPLE__
if (ctx->synth) {
ctx->restarting = TRUE;
en_mac_stop(ctx);
ctx->restarting = FALSE;
en_mac_start(ctx);
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return;
}
#endif
if (ctx->fallback) {
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
return;
}
if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
kill((pid_t)ctx->pid, SIGKILL);
#endif
}
}
static gboolean en_apply_voice(gpointer data) {
EnEx *ctx = data;
ctx->apply_src = 0;
if (ctx->busy && !ctx->paused)
en_restart_voice(ctx);
return G_SOURCE_REMOVE;
}
static void en_level_changed(GtkRange *range, gpointer data) {
EnEx *ctx = data;
const char *which = g_object_get_data(G_OBJECT(range), "en-level");
if (g_strcmp0(which, "speed") == 0)
ctx->speed = gtk_range_get_value(range);
else
ctx->gain = gtk_range_get_value(range) / 100.0;
ctx->voice_dirty = TRUE;
if (ctx->apply_src)
g_source_remove(ctx->apply_src);
ctx->apply_src = g_timeout_add(180, en_apply_voice, ctx);
}
static void en_play_clicked(GtkButton *button, gpointer data) {
EnEx *ctx = data;
(void)button;
if (!ctx->lesson->listening)
return;
ctx->played = TRUE;
if (!ctx->busy) {
en_launch(ctx);
return;
}
if (ctx->paused)
en_resume_voice(ctx);
else
en_pause_voice(ctx);
}
static GtkWidget *en_labeled_scale(EnEx *ctx, const char *key, const char *which,
double min, double max, double value, int digits) {
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
GtkWidget *lab = gtk_label_new(NULL);
GtkWidget *scale = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL,
min, max, max > 10 ? 1 : 0.05);
i18n_bind(lab, key, 0);
gtk_widget_set_size_request(lab, 96, -1);
gtk_label_set_xalign(GTK_LABEL(lab), 0);
gtk_range_set_value(GTK_RANGE(scale), value);
gtk_scale_set_digits(GTK_SCALE(scale), digits);
gtk_widget_set_hexpand(scale, TRUE);
g_object_set_data(G_OBJECT(scale), "en-level", (gpointer)which);
g_signal_connect(scale, "value-changed", G_CALLBACK(en_level_changed), ctx);
gtk_box_append(GTK_BOX(row), lab);
gtk_box_append(GTK_BOX(row), scale);
return row;
}
static void en_check(GtkButton *button, gpointer data) {
@ -1171,12 +1482,20 @@ static GtkWidget *build_en_ex_page(int year, int index) {
gtk_widget_add_css_class(note, "quiz-intro");
gtk_box_append(GTK_BOX(body), note);
play = gtk_button_new();
i18n_bind(play, "en_play", 1);
ctx->speed = 0.9;
ctx->gain = 1.0;
play = gtk_button_new_with_label(tr("en_play"));
gtk_widget_add_css_class(play, "btn-primary");
gtk_widget_set_halign(play, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(body), play);
ctx->play_btn = play;
g_signal_connect(play, "clicked", G_CALLBACK(en_play_clicked), ctx);
gtk_box_append(GTK_BOX(body),
en_labeled_scale(ctx, "en_speed", "speed",
0.6, 1.5, ctx->speed, 1));
gtk_box_append(GTK_BOX(body),
en_labeled_scale(ctx, "en_volume", "volume",
0, 100, ctx->gain * 100.0, 0));
ctx->play_note = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(ctx->play_note), TRUE);

View file

@ -10,6 +10,10 @@
{"en_sec_quiz", "Kvíz", "Quiz"},
{"en_sec_test", "Jazyková část", "Language"},
{"en_play", "Přehrát", "Play"},
{"en_pause", "Pauza", "Pause"},
{"en_resume", "Pokračovat", "Resume"},
{"en_speed", "Rychlost", "Speed"},
{"en_volume", "Hlasitost", "Volume"},
{"en_listen_note", "Nejdřív si přečtěte otázky. Nahrávku přehraje hlas zařízení. Bez něj se věty ukážou postupně.", "Read the questions first. The device voice plays the recording. Without one, the sentences appear one by one."},
{"en_listen_first", "Nejdřív poslech přehrajte.", "Play the recording first."},
{"en_listen_fallback", "Hlas není dostupný, věty se ukazují postupně.", "No voice is available, so the sentences appear one by one."},