summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJaakko Keränen <jaakko.keranen@iki.fi>2020-10-04 17:20:41 +0300
committerJaakko Keränen <jaakko.keranen@iki.fi>2020-10-04 17:20:41 +0300
commitefb40105d657da935d3854e6ea7a513c6210224b (patch)
tree0327d88d68c0a402f0f90307756bb8536c54dc8b /src
parent25346114f96a29e8af6125e0cac3d5f8a2ffd551 (diff)
Working on audio playback
Audio players are displayed the same way as images. When playing, a decoder runs in a background thread producing samples suitable for output.
Diffstat (limited to 'src')
-rw-r--r--src/audio/player.c337
-rw-r--r--src/audio/player.h5
-rw-r--r--src/gmdocument.c42
-rw-r--r--src/gmdocument.h3
-rw-r--r--src/gmrequest.c3
-rw-r--r--src/media.c135
-rw-r--r--src/media.h24
-rw-r--r--src/ui/documentwidget.c76
8 files changed, 524 insertions, 101 deletions
diff --git a/src/audio/player.c b/src/audio/player.c
index aea2a998..1dd3c38a 100644
--- a/src/audio/player.c
+++ b/src/audio/player.c
@@ -22,12 +22,122 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
22 22
23#include "player.h" 23#include "player.h"
24 24
25#include <the_Foundation/buffer.h>
25#include <the_Foundation/thread.h> 26#include <the_Foundation/thread.h>
26#include <SDL_audio.h> 27#include <SDL_audio.h>
27 28
29iDeclareType(InputBuf)
30
31struct Impl_InputBuf {
32 iMutex mtx;
33 iCondition changed;
34 iBlock data;
35 iBool isComplete;
36};
37
38void init_InputBuf(iInputBuf *d) {
39 init_Mutex(&d->mtx);
40 init_Condition(&d->changed);
41 init_Block(&d->data, 0);
42 d->isComplete = iTrue;
43}
44
45void deinit_InputBuf(iInputBuf *d) {
46 deinit_Block(&d->data);
47 deinit_Condition(&d->changed);
48 deinit_Mutex(&d->mtx);
49}
50
51size_t size_InputBuf(const iInputBuf *d) {
52 return size_Block(&d->data);
53}
54
55iDefineTypeConstruction(InputBuf)
56
57/*----------------------------------------------------------------------------------------------*/
58
59iDeclareType(SampleBuf)
60
61struct Impl_SampleBuf {
62 void * data;
63 size_t sampleSize;
64 size_t count;
65 size_t head, tail;
66};
67
68void init_SampleBuf(iSampleBuf *d, size_t sampleSize, size_t count) {
69 d->sampleSize = sampleSize;
70 d->count = count + 1; /* considered empty if head==tail */
71 d->data = malloc(d->sampleSize * d->count);
72 d->head = 0;
73 d->tail = 0;
74}
75
76void deinit_SampleBuf(iSampleBuf *d) {
77 free(d->data);
78}
79
80size_t size_SampleBuf(const iSampleBuf *d) {
81 return d->head - d->tail;
82}
83
84size_t vacancy_SampleBuf(const iSampleBuf *d) {
85 return d->count - size_SampleBuf(d) - 1;
86}
87
88iBool isFull_SampleBuf(const iSampleBuf *d) {
89 return vacancy_SampleBuf(d) == 0;
90}
91
92iLocalDef void *ptr_SampleBuf_(iSampleBuf *d, size_t pos) {
93 return ((char *) d->data) + (d->sampleSize * pos);
94}
95
96void write_SampleBuf(iSampleBuf *d, const void *samples, const size_t n) {
97 iAssert(n <= vacancy_SampleBuf(d));
98 const size_t headPos = d->head % d->count;
99 const size_t avail = d->count - headPos;
100 if (n > avail) {
101 const char *in = samples;
102 memcpy(ptr_SampleBuf_(d, headPos), in, d->sampleSize * avail);
103 in += d->sampleSize * avail;
104 memcpy(ptr_SampleBuf_(d, 0), in, d->sampleSize * (n - avail));
105 }
106 else {
107 memcpy(ptr_SampleBuf_(d, headPos), samples, d->sampleSize * n);
108 }
109 d->head += n;
110}
111
112void read_SampleBuf(iSampleBuf *d, const size_t n, void *samples_out) {
113 iAssert(n <= size_SampleBuf(d));
114 const size_t tailPos = d->tail % d->count;
115 const size_t avail = d->count - tailPos;
116 if (n > avail) {
117 char *out = samples_out;
118 memcpy(out, ptr_SampleBuf_(d, tailPos), d->sampleSize * avail);
119 out += d->sampleSize * avail;
120 memcpy(out, ptr_SampleBuf_(d, 0), d->sampleSize * (n - avail));
121 }
122 else {
123 memcpy(samples_out, ptr_SampleBuf_(d, tailPos), d->sampleSize * n);
124 }
125 d->tail += n;
126}
127
128/*----------------------------------------------------------------------------------------------*/
129
130iDeclareType(ContentSpec)
131
132struct Impl_ContentSpec {
133 SDL_AudioSpec spec;
134 iRanges wavData;
135};
136
28iDeclareType(Decoder) 137iDeclareType(Decoder)
29 138
30enum iDecoderType { 139enum iDecoderType {
140 none_DecoderType,
31 wav_DecoderType, 141 wav_DecoderType,
32 mpeg_DecoderType, 142 mpeg_DecoderType,
33 vorbis_DecoderType, 143 vorbis_DecoderType,
@@ -36,29 +146,189 @@ enum iDecoderType {
36 146
37struct Impl_Decoder { 147struct Impl_Decoder {
38 enum iDecoderType type; 148 enum iDecoderType type;
39 size_t inPos; 149 iThread * thread;
40// iBlock samples; 150 iInputBuf * input;
151 size_t inputPos;
152 iSampleBuf output;
153 iMutex mtx; /* for output */
154 iRanges wavData;
41}; 155};
42 156
157static void parseWav_Decoder_(iDecoder *d, iRanges inputRange) {
158 lock_Mutex(&d->mtx);
159 const size_t vacancy = vacancy_SampleBuf(&d->output);
160 const size_t avail = iMin(inputRange.end - d->inputPos, d->wavData.end - d->inputPos) /
161 d->output.sampleSize;
162 const size_t n = iMin(vacancy, avail);
163 iGuardMutex(&d->input->mtx, /* lock so input array isn't reallocated during the copy */
164 write_SampleBuf(&d->output, constData_Block(&d->input->data) + d->inputPos, n));
165 d->inputPos += n;
166 unlock_Mutex(&d->mtx);
167}
168
169static iThreadResult run_Decoder_(iThread *thread) {
170 iDecoder *d = userData_Thread(thread);
171 while (d->type) {
172 size_t inputSize = 0;
173 /* Grab more input. */ {
174 lock_Mutex(&d->input->mtx);
175 wait_Condition(&d->input->changed, &d->input->mtx);
176 inputSize = size_Block(&d->input->data);
177 unlock_Mutex(&d->input->mtx);
178 }
179 iRanges inputRange = { d->inputPos, inputSize };
180 iAssert(inputRange.start <= inputRange.end);
181 if (!d->type) break;
182 /* Have data to work on and a place to save output? */
183 if (!isEmpty_Range(&inputRange) && !isFull_SampleBuf(&d->output)) {
184 switch (d->type) {
185 case wav_DecoderType:
186 parseWav_Decoder_(d, inputRange);
187 break;
188 default:
189 break;
190 }
191 }
192 }
193 return 0;
194}
195
196void init_Decoder(iDecoder *d, iInputBuf *input, const iContentSpec *spec) {
197 d->type = wav_DecoderType;
198 d->input = input;
199 d->inputPos = spec->wavData.start;
200 init_SampleBuf(&d->output, SDL_AUDIO_BITSIZE(spec->spec.format) / 8 *
201 spec->spec.channels, spec->spec.samples * 2);
202 init_Mutex(&d->mtx);
203 d->thread = new_Thread(run_Decoder_);
204 setUserData_Thread(d->thread, d);
205 start_Thread(d->thread);
206}
207
208void deinit_Decoder(iDecoder *d) {
209 d->type = none_DecoderType;
210 signal_Condition(&d->input->changed);
211 join_Thread(d->thread);
212 iRelease(d->thread);
213 deinit_Mutex(&d->mtx);
214 deinit_SampleBuf(&d->output);
215}
216
217iDefineTypeConstructionArgs(Decoder, (iInputBuf *input, const iContentSpec *spec),
218 input, spec)
219
220/*----------------------------------------------------------------------------------------------*/
221
43struct Impl_Player { 222struct Impl_Player {
44 SDL_AudioSpec spec; 223 SDL_AudioSpec spec;
45 SDL_AudioDeviceID device; 224 SDL_AudioDeviceID device;
46 iMutex mtx; 225 iInputBuf *data;
47 iBlock data; 226 iDecoder *decoder;
48 iBool isDataComplete;
49}; 227};
50 228
229iDefineTypeConstruction(Player)
230
231static size_t sampleSize_Player_(const iPlayer *d) {
232 return d->spec.channels * SDL_AUDIO_BITSIZE(d->spec.format) / 8;
233}
234
235static int silence_Player_(const iPlayer *d) {
236 return d->spec.silence;
237}
238
239static iContentSpec contentSpec_Player_(const iPlayer *d) {
240 iContentSpec content;
241 iZap(content);
242 const size_t dataSize = size_InputBuf(d->data);
243 iBuffer *buf = iClob(new_Buffer());
244 open_Buffer(buf, &d->data->data);
245 enum iDecoderType decType = wav_DecoderType; /* TODO: from MIME */
246 if (decType == wav_DecoderType && dataSize >= 44) {
247 /* Read the RIFF/WAVE header. */
248 iStream *is = stream_Buffer(buf);
249 char magic[4];
250 readData_Buffer(buf, 4, magic);
251 if (memcmp(magic, "RIFF", 4)) {
252 /* Not WAV. */
253 return content;
254 }
255 readU32_Stream(is); /* file size */
256 readData_Buffer(buf, 4, magic);
257 if (memcmp(magic, "WAVE", 4)) {
258 /* Not WAV. */
259 return content;
260 }
261 /* Read all the chunks. */
262 while (!atEnd_Buffer(buf)) {
263 readData_Buffer(buf, 4, magic);
264 const size_t size = read32_Stream(is);
265 if (memcmp(magic, "fmt ", 4) == 0) {
266 if (size != 16) {
267 return content;
268 }
269 const int16_t mode = read16_Stream(is); /* 1 = PCM */
270 const int16_t numChannels = read16_Stream(is);
271 const int32_t freq = read32_Stream(is);
272 const uint32_t bytesPerSecond = readU32_Stream(is);
273 const int16_t blockAlign = read16_Stream(is);
274 const int16_t bitsPerSample = read16_Stream(is);
275 iUnused(bytesPerSecond);
276 iUnused(blockAlign); /* TODO: Should use this one when reading samples? */
277 if (mode != 1) { /* PCM */
278 return content;
279 }
280 if (numChannels != 1 && numChannels != 2) {
281 return content;
282 }
283 if (bitsPerSample != 8 && bitsPerSample != 16 && bitsPerSample != 32) {
284 return content;
285 }
286 content.spec.freq = freq;
287 content.spec.channels = numChannels;
288 content.spec.format =
289 (bitsPerSample == 8 ? AUDIO_S8 : bitsPerSample == 16 ? AUDIO_S16 : AUDIO_S32);
290 }
291 else if (memcmp(magic, "data", 4) == 0) {
292 size_t len = read32_Stream(is); /* data size */
293 content.wavData = (iRanges){ pos_Stream(is), pos_Stream(is) + len };
294 break;
295 }
296 else {
297 seek_Stream(is, pos_Stream(is) + size);
298 }
299 }
300 }
301 content.spec.samples = 2048;
302 return content;
303}
304
305static void writeOutputSamples_Player_(void *plr, Uint8 *stream, int len) {
306 iPlayer *d = plr;
307 iAssert(d->decoder);
308 const size_t sampleSize = sampleSize_Player_(d);
309 const size_t count = len / sampleSize;
310 lock_Mutex(&d->decoder->mtx);
311 if (size_SampleBuf(&d->decoder->output) >= count) {
312 read_SampleBuf(&d->decoder->output, count, stream);
313 }
314 else {
315 memset(stream, 0, len); /* TODO: If unsigned samples, don't use zero! */
316 }
317 unlock_Mutex(&d->decoder->mtx);
318 /* Wake up decoder; there is more room for output. */
319 signal_Condition(&d->data->changed);
320}
321
51void init_Player(iPlayer *d) { 322void init_Player(iPlayer *d) {
52 iZap(d->spec); 323 iZap(d->spec);
53 d->device = 0; 324 d->device = 0;
54 init_Mutex(&d->mtx); 325 d->decoder = NULL;
55 init_Block(&d->data, 0); 326 d->data = new_InputBuf();
56 d->isDataComplete = iFalse;
57} 327}
58 328
59void deinit_Player(iPlayer *d) { 329void deinit_Player(iPlayer *d) {
60 stop_Player(d); 330 stop_Player(d);
61 deinit_Block(&d->data); 331 delete_InputBuf(d->data);
62} 332}
63 333
64iBool isStarted_Player(const iPlayer *d) { 334iBool isStarted_Player(const iPlayer *d) {
@@ -70,55 +340,54 @@ void setFormatHint_Player(iPlayer *d, const char *hint) {
70} 340}
71 341
72void updateSourceData_Player(iPlayer *d, const iBlock *data, enum iPlayerUpdate update) { 342void updateSourceData_Player(iPlayer *d, const iBlock *data, enum iPlayerUpdate update) {
73 lock_Mutex(&d->mtx); 343 iInputBuf *inp = d->data;
344 lock_Mutex(&inp->mtx);
74 switch (update) { 345 switch (update) {
75 case replace_PlayerUpdate: 346 case replace_PlayerUpdate:
76 set_Block(&d->data, data); 347 set_Block(&inp->data, data);
77 d->isDataComplete = iFalse; 348 inp->isComplete = iFalse;
78 break; 349 break;
79 case append_PlayerUpdate: 350 case append_PlayerUpdate:
80 append_Block(&d->data, data); 351 append_Block(&inp->data, data);
81 d->isDataComplete = iFalse; 352 inp->isComplete = iFalse;
82 break; 353 break;
83 case complete_PlayerUpdate: 354 case complete_PlayerUpdate:
84 d->isDataComplete = iTrue; 355 inp->isComplete = iTrue;
85 break; 356 break;
86 } 357 }
87 unlock_Mutex(&d->mtx); 358 signal_Condition(&inp->changed);
88} 359 unlock_Mutex(&inp->mtx);
89
90static void writeOutputSamples_Player_(void *plr, Uint8 *stream, int len) {
91 iPlayer *d = plr;
92 memset(stream, 0, len);
93 /* TODO: Copy samples from the decoder's ring buffer. */
94} 360}
95 361
96iBool start_Player(iPlayer *d) { 362iBool start_Player(iPlayer *d) {
97 if (isStarted_Player(d)) { 363 if (isStarted_Player(d)) {
98 return iFalse; 364 return iFalse;
99 } 365 }
100 SDL_AudioSpec conf; 366 iContentSpec content = contentSpec_Player_(d);
101 iZap(conf); 367 content.spec.callback = writeOutputSamples_Player_;
102 conf.freq = 44100; /* TODO: from content */ 368 content.spec.userdata = d;
103 conf.format = AUDIO_S16; 369 d->device = SDL_OpenAudioDevice(NULL, SDL_FALSE /* playback */, &content.spec, &d->spec, 0);
104 conf.channels = 2; /* TODO: from content */
105 conf.samples = 2048;
106 conf.callback = writeOutputSamples_Player_;
107 conf.userdata = d;
108 d->device = SDL_OpenAudioDevice(NULL, SDL_FALSE /* playback */, &conf, &d->spec, 0);
109 if (!d->device) { 370 if (!d->device) {
110 return iFalse; 371 return iFalse;
111 } 372 }
112 /* TODO: Start the stream/decoder thread. */ 373 d->decoder = new_Decoder(d->data, &content);
113 /* TODO: Audio device is unpaused when there are samples ready to play. */ 374 SDL_PauseAudioDevice(d->device, SDL_FALSE);
114 return iTrue; 375 return iTrue;
115} 376}
116 377
378void setPaused_Player(iPlayer *d, iBool isPaused) {
379 if (isStarted_Player(d)) {
380 SDL_PauseAudioDevice(d->device, isPaused ? SDL_TRUE : SDL_FALSE);
381 }
382}
383
117void stop_Player(iPlayer *d) { 384void stop_Player(iPlayer *d) {
118 if (isStarted_Player(d)) { 385 if (isStarted_Player(d)) {
119 /* TODO: Stop the stream/decoder. */ 386 /* TODO: Stop the stream/decoder. */
120 SDL_PauseAudioDevice(d->device, SDL_TRUE); 387 SDL_PauseAudioDevice(d->device, SDL_TRUE);
121 SDL_CloseAudioDevice(d->device); 388 SDL_CloseAudioDevice(d->device);
122 d->device = 0; 389 d->device = 0;
390 delete_Decoder(d->decoder);
391 d->decoder = NULL;
123 } 392 }
124} 393}
diff --git a/src/audio/player.h b/src/audio/player.h
index 121659f2..2138c556 100644
--- a/src/audio/player.h
+++ b/src/audio/player.h
@@ -36,7 +36,8 @@ enum iPlayerUpdate {
36void setFormatHint_Player (iPlayer *, const char *hint); 36void setFormatHint_Player (iPlayer *, const char *hint);
37void updateSourceData_Player (iPlayer *, const iBlock *data, enum iPlayerUpdate update); 37void updateSourceData_Player (iPlayer *, const iBlock *data, enum iPlayerUpdate update);
38 38
39iBool start_Player (iPlayer *); 39iBool start_Player (iPlayer *);
40void stop_Player (iPlayer *); 40void setPaused_Player (iPlayer *, iBool isPaused);
41void stop_Player (iPlayer *);
41 42
42iBool isStarted_Player (const iPlayer *); 43iBool isStarted_Player (const iPlayer *);
diff --git a/src/gmdocument.c b/src/gmdocument.c
index 6e139e45..ff0e019b 100644
--- a/src/gmdocument.c
+++ b/src/gmdocument.c
@@ -308,6 +308,7 @@ static void doLayout_GmDocument_(iGmDocument *d) {
308 run.color = white_ColorId; 308 run.color = white_ColorId;
309 run.linkId = 0; 309 run.linkId = 0;
310 run.imageId = 0; 310 run.imageId = 0;
311 run.audioId = 0;
311 enum iGmLineType type; 312 enum iGmLineType type;
312 int indent = 0; 313 int indent = 0;
313 /* Detect the type of the line. */ 314 /* Detect the type of the line. */
@@ -429,12 +430,12 @@ static void doLayout_GmDocument_(iGmDocument *d) {
429 } 430 }
430 /* Quote icon. */ 431 /* Quote icon. */
431 if (type == quote_GmLineType && addQuoteIcon) { 432 if (type == quote_GmLineType && addQuoteIcon) {
432 addQuoteIcon = iFalse; 433 addQuoteIcon = iFalse;
433 iGmRun quoteRun = run; 434 iGmRun quoteRun = run;
434 quoteRun.font = heading1_FontId; 435 quoteRun.font = heading1_FontId;
435 quoteRun.text = range_CStr(quote); 436 quoteRun.text = range_CStr(quote);
436 quoteRun.color = tmQuoteIcon_ColorId; 437 quoteRun.color = tmQuoteIcon_ColorId;
437 iRect vis = visualBounds_Text(quoteRun.font, quoteRun.text); 438 iRect vis = visualBounds_Text(quoteRun.font, quoteRun.text);
438 quoteRun.visBounds.size = advance_Text(quoteRun.font, quote); 439 quoteRun.visBounds.size = advance_Text(quoteRun.font, quote);
439 quoteRun.visBounds.pos = 440 quoteRun.visBounds.pos =
440 add_I2(pos, 441 add_I2(pos,
@@ -522,6 +523,7 @@ static void doLayout_GmDocument_(iGmDocument *d) {
522 /* Image content. */ 523 /* Image content. */
523 if (type == link_GmLineType) { 524 if (type == link_GmLineType) {
524 const iMediaId imageId = findLinkImage_Media(d->media, run.linkId); 525 const iMediaId imageId = findLinkImage_Media(d->media, run.linkId);
526 const iMediaId audioId = !imageId ? findLinkAudio_Media(d->media, run.linkId) : 0;
525 if (imageId) { 527 if (imageId) {
526 iGmImageInfo img; 528 iGmImageInfo img;
527 imageInfo_Media(d->media, imageId, &img); 529 imageInfo_Media(d->media, imageId, &img);
@@ -532,7 +534,7 @@ static void doLayout_GmDocument_(iGmDocument *d) {
532 link->flags |= permanent_GmLinkFlag; 534 link->flags |= permanent_GmLinkFlag;
533 } 535 }
534 } 536 }
535 const int margin = 0.5f * lineHeight_Text(paragraph_FontId); 537 const int margin = lineHeight_Text(paragraph_FontId) / 2;
536 pos.y += margin; 538 pos.y += margin;
537 run.bounds.pos = pos; 539 run.bounds.pos = pos;
538 run.bounds.size.x = d->size.x; 540 run.bounds.size.x = d->size.x;
@@ -554,6 +556,28 @@ static void doLayout_GmDocument_(iGmDocument *d) {
554 pushBack_Array(&d->layout, &run); 556 pushBack_Array(&d->layout, &run);
555 pos.y += run.bounds.size.y + margin; 557 pos.y += run.bounds.size.y + margin;
556 } 558 }
559 else if (audioId) {
560 iGmAudioInfo info;
561 audioInfo_Media(d->media, audioId, &info);
562 /* Mark the link as having content. */ {
563 iGmLink *link = at_PtrArray(&d->links, run.linkId - 1);
564 link->flags |= content_GmLinkFlag;
565 if (info.isPermanent) {
566 link->flags |= permanent_GmLinkFlag;
567 }
568 }
569 const int margin = lineHeight_Text(paragraph_FontId) / 2;
570 pos.y += margin;
571 run.bounds.pos = pos;
572 run.bounds.size.x = d->size.x;
573 run.bounds.size.y = 2 * lineHeight_Text(uiContent_FontId);
574 run.visBounds = run.bounds;
575 run.text = iNullRange;
576 run.color = 0;
577 run.audioId = audioId;
578 pushBack_Array(&d->layout, &run);
579 pos.y += run.bounds.size.y + margin;
580 }
557 } 581 }
558 prevType = type; 582 prevType = type;
559 } 583 }
@@ -1120,10 +1144,14 @@ const iTime *linkTime_GmDocument(const iGmDocument *d, iGmLinkId linkId) {
1120 return link ? &link->when : NULL; 1144 return link ? &link->when : NULL;
1121} 1145}
1122 1146
1123uint16_t linkImage_GmDocument(const iGmDocument *d, iGmLinkId linkId) { 1147iMediaId linkImage_GmDocument(const iGmDocument *d, iGmLinkId linkId) {
1124 return findLinkImage_Media(d->media, linkId); 1148 return findLinkImage_Media(d->media, linkId);
1125} 1149}
1126 1150
1151iMediaId linkAudio_GmDocument(const iGmDocument *d, iGmLinkId linkId) {
1152 return findLinkAudio_Media(d->media, linkId);
1153}
1154
1127enum iColorId linkColor_GmDocument(const iGmDocument *d, iGmLinkId linkId, enum iGmLinkPart part) { 1155enum iColorId linkColor_GmDocument(const iGmDocument *d, iGmLinkId linkId, enum iGmLinkPart part) {
1128 const iGmLink *link = link_GmDocument_(d, linkId); 1156 const iGmLink *link = link_GmDocument_(d, linkId);
1129 const int www_GmLinkFlag = http_GmLinkFlag | mailto_GmLinkFlag; 1157 const int www_GmLinkFlag = http_GmLinkFlag | mailto_GmLinkFlag;
diff --git a/src/gmdocument.h b/src/gmdocument.h
index a04398d9..19a6036f 100644
--- a/src/gmdocument.h
+++ b/src/gmdocument.h
@@ -132,7 +132,8 @@ const iGmRun * findRun_GmDocument (const iGmDocument *, iInt2 pos);
132const char * findLoc_GmDocument (const iGmDocument *, iInt2 pos); 132const char * findLoc_GmDocument (const iGmDocument *, iInt2 pos);
133const iGmRun * findRunAtLoc_GmDocument (const iGmDocument *, const char *loc); 133const iGmRun * findRunAtLoc_GmDocument (const iGmDocument *, const char *loc);
134const iString * linkUrl_GmDocument (const iGmDocument *, iGmLinkId linkId); 134const iString * linkUrl_GmDocument (const iGmDocument *, iGmLinkId linkId);
135uint16_t linkImage_GmDocument (const iGmDocument *, iGmLinkId linkId); 135iMediaId linkImage_GmDocument (const iGmDocument *, iGmLinkId linkId);
136iMediaId linkAudio_GmDocument (const iGmDocument *, iGmLinkId linkId);
136int linkFlags_GmDocument (const iGmDocument *, iGmLinkId linkId); 137int linkFlags_GmDocument (const iGmDocument *, iGmLinkId linkId);
137enum iColorId linkColor_GmDocument (const iGmDocument *, iGmLinkId linkId, enum iGmLinkPart part); 138enum iColorId linkColor_GmDocument (const iGmDocument *, iGmLinkId linkId, enum iGmLinkPart part);
138const iTime * linkTime_GmDocument (const iGmDocument *, iGmLinkId linkId); 139const iTime * linkTime_GmDocument (const iGmDocument *, iGmLinkId linkId);
diff --git a/src/gmrequest.c b/src/gmrequest.c
index b667c82a..161c654c 100644
--- a/src/gmrequest.c
+++ b/src/gmrequest.c
@@ -454,6 +454,9 @@ void submit_GmRequest(iGmRequest *d) {
454 else if (endsWithCase_String(path, ".gif")) { 454 else if (endsWithCase_String(path, ".gif")) {
455 setCStr_String(&d->resp.meta, "image/gif"); 455 setCStr_String(&d->resp.meta, "image/gif");
456 } 456 }
457 else if (endsWithCase_String(path, ".wav")) {
458 setCStr_String(&d->resp.meta, "audio/wave");
459 }
457 else { 460 else {
458 setCStr_String(&d->resp.meta, "application/octet-stream"); 461 setCStr_String(&d->resp.meta, "application/octet-stream");
459 } 462 }
diff --git a/src/media.c b/src/media.c
index 9f6acd19..5d88fbc4 100644
--- a/src/media.c
+++ b/src/media.c
@@ -23,26 +23,44 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
23#include "media.h" 23#include "media.h"
24#include "gmdocument.h" 24#include "gmdocument.h"
25#include "ui/window.h" 25#include "ui/window.h"
26#include "audio/player.h"
26 27
27#include <the_Foundation/ptrarray.h> 28#include <the_Foundation/ptrarray.h>
28#include <stb_image.h> 29#include <stb_image.h>
29#include <SDL_hints.h> 30#include <SDL_hints.h>
30#include <SDL_render.h> 31#include <SDL_render.h>
31 32
33iDeclareType(GmMediaProps)
34
35struct Impl_GmMediaProps {
36 iGmLinkId linkId;
37 iString mime;
38 iBool isPermanent;
39};
40
41static void init_GmMediaProps_(iGmMediaProps *d) {
42 d->linkId = 0;
43 init_String(&d->mime);
44 d->isPermanent = iFalse;
45}
46
47static void deinit_GmMediaProps_(iGmMediaProps *d) {
48 deinit_String(&d->mime);
49}
50
51/*----------------------------------------------------------------------------------------------*/
52
32iDeclareType(GmImage) 53iDeclareType(GmImage)
33 54
34struct Impl_GmImage { 55struct Impl_GmImage {
56 iGmMediaProps props;
35 iInt2 size; 57 iInt2 size;
36 size_t numBytes; 58 size_t numBytes;
37 iString mime;
38 iGmLinkId linkId;
39 iBool isPermanent;
40 SDL_Texture *texture; 59 SDL_Texture *texture;
41}; 60};
42 61
43void init_GmImage(iGmImage *d, const iBlock *data) { 62void init_GmImage(iGmImage *d, const iBlock *data) {
44 init_String(&d->mime); 63 init_GmMediaProps_(&d->props);
45 d->isPermanent = iFalse;
46 d->numBytes = size_Block(data); 64 d->numBytes = size_Block(data);
47 uint8_t *imgData = stbi_load_from_memory( 65 uint8_t *imgData = stbi_load_from_memory(
48 constData_Block(data), size_Block(data), &d->size.x, &d->size.y, NULL, 4); 66 constData_Block(data), size_Block(data), &d->size.x, &d->size.y, NULL, 4);
@@ -60,30 +78,53 @@ void init_GmImage(iGmImage *d, const iBlock *data) {
60 SDL_FreeSurface(surface); 78 SDL_FreeSurface(surface);
61 stbi_image_free(imgData); 79 stbi_image_free(imgData);
62 } 80 }
63 d->linkId = 0;
64} 81}
65 82
66void deinit_GmImage(iGmImage *d) { 83void deinit_GmImage(iGmImage *d) {
67 SDL_DestroyTexture(d->texture); 84 SDL_DestroyTexture(d->texture);
68 deinit_String(&d->mime); 85 deinit_GmMediaProps_(&d->props);
69} 86}
70 87
71iDefineTypeConstructionArgs(GmImage, (const iBlock *data), data) 88iDefineTypeConstructionArgs(GmImage, (const iBlock *data), data)
72 89
73/*----------------------------------------------------------------------------------------------*/ 90/*----------------------------------------------------------------------------------------------*/
74 91
92iDeclareType(GmAudio)
93
94struct Impl_GmAudio {
95 iGmMediaProps props;
96 iPlayer *player;
97};
98
99void init_GmAudio(iGmAudio *d) {
100 init_GmMediaProps_(&d->props);
101 d->player = new_Player();
102}
103
104void deinit_GmAudio(iGmAudio *d) {
105 delete_Player(d->player);
106 deinit_GmMediaProps_(&d->props);
107}
108
109iDefineTypeConstruction(GmAudio)
110
111/*----------------------------------------------------------------------------------------------*/
112
75struct Impl_Media { 113struct Impl_Media {
76 iPtrArray images; 114 iPtrArray images;
115 iPtrArray audio;
77}; 116};
78 117
79iDefineTypeConstruction(Media) 118iDefineTypeConstruction(Media)
80 119
81void init_Media(iMedia *d) { 120void init_Media(iMedia *d) {
82 init_PtrArray(&d->images); 121 init_PtrArray(&d->images);
122 init_PtrArray(&d->audio);
83} 123}
84 124
85void deinit_Media(iMedia *d) { 125void deinit_Media(iMedia *d) {
86 clear_Media(d); 126 clear_Media(d);
127 deinit_PtrArray(&d->audio);
87 deinit_PtrArray(&d->images); 128 deinit_PtrArray(&d->images);
88} 129}
89 130
@@ -92,25 +133,35 @@ void clear_Media(iMedia *d) {
92 deinit_GmImage(i.ptr); 133 deinit_GmImage(i.ptr);
93 } 134 }
94 clear_PtrArray(&d->images); 135 clear_PtrArray(&d->images);
136 iForEach(PtrArray, a, &d->audio) {
137 deinit_GmAudio(a.ptr);
138 }
139 clear_PtrArray(&d->audio);
95} 140}
96 141
97void setImage_Media(iMedia *d, iGmLinkId linkId, const iString *mime, const iBlock *data, 142void setData_Media(iMedia *d, iGmLinkId linkId, const iString *mime, const iBlock *data,
98 iBool allowHide) { 143 iBool allowHide) {
99 if (!mime || !data) { 144 if (!mime || !data) {
100 iGmImage *img; 145 iMediaId existing = findLinkImage_Media(d, linkId);
101 const iMediaId existing = findLinkImage_Media(d, linkId);
102 if (existing) { 146 if (existing) {
147 iGmImage *img;
103 take_PtrArray(&d->images, existing - 1, (void **) &img); 148 take_PtrArray(&d->images, existing - 1, (void **) &img);
104 delete_GmImage(img); 149 delete_GmImage(img);
105 } 150 }
151 else if ((existing = findLinkAudio_Media(d, linkId)) != 0) {
152 iGmAudio *audio;
153 take_PtrArray(&d->audio, existing - 1, (void **) &audio);
154 delete_GmAudio(audio);
155 }
106 } 156 }
107 else { 157 else {
108 /* TODO: check if we know this MIME type */ 158 if (startsWith_String(mime, "image/")) {
109 /* Upload the image. */ { 159 /* Copy the image to a texture. */
160 /* TODO: Resize down to min(maximum texture size, window size). */
110 iGmImage *img = new_GmImage(data); 161 iGmImage *img = new_GmImage(data);
111 img->linkId = linkId; /* TODO: use a hash? */ 162 img->props.linkId = linkId; /* TODO: use a hash? */
112 img->isPermanent = !allowHide; 163 img->props.isPermanent = !allowHide;
113 set_String(&img->mime, mime); 164 set_String(&img->props.mime, mime);
114 if (img->texture) { 165 if (img->texture) {
115 pushBack_PtrArray(&d->images, img); 166 pushBack_PtrArray(&d->images, img);
116 } 167 }
@@ -118,15 +169,34 @@ void setImage_Media(iMedia *d, iGmLinkId linkId, const iString *mime, const iBlo
118 delete_GmImage(img); 169 delete_GmImage(img);
119 } 170 }
120 } 171 }
172 else if (startsWith_String(mime, "audio/")) {
173 iGmAudio *audio = new_GmAudio();
174 audio->props.linkId = linkId;
175 audio->props.isPermanent = !allowHide;
176 set_String(&audio->props.mime, mime);
177 /* TODO: What about update/complete, for streaming? */
178 updateSourceData_Player(audio->player, data, replace_PlayerUpdate);
179 pushBack_PtrArray(&d->audio, audio);
180 }
121 } 181 }
122// doLayout_GmDocument_(d);
123} 182}
124 183
125iMediaId findLinkImage_Media(const iMedia *d, iGmLinkId linkId) { 184iMediaId findLinkImage_Media(const iMedia *d, iGmLinkId linkId) {
126 /* TODO: use a hash */ 185 /* TODO: use a hash */
127 iConstForEach(PtrArray, i, &d->images) { 186 iConstForEach(PtrArray, i, &d->images) {
128 const iGmImage *img = i.ptr; 187 const iGmImage *img = i.ptr;
129 if (img->linkId == linkId) { 188 if (img->props.linkId == linkId) {
189 return index_PtrArrayConstIterator(&i) + 1;
190 }
191 }
192 return 0;
193}
194
195iMediaId findLinkAudio_Media(const iMedia *d, iGmLinkId linkId) {
196 /* TODO: use a hash */
197 iConstForEach(PtrArray, i, &d->audio) {
198 const iGmAudio *audio = i.ptr;
199 if (audio->props.linkId == linkId) {
130 return index_PtrArrayConstIterator(&i) + 1; 200 return index_PtrArrayConstIterator(&i) + 1;
131 } 201 }
132 } 202 }
@@ -141,15 +211,34 @@ SDL_Texture *imageTexture_Media(const iMedia *d, uint16_t imageId) {
141 return NULL; 211 return NULL;
142} 212}
143 213
144void imageInfo_Media(const iMedia *d, uint16_t imageId, iGmImageInfo *info_out) { 214iBool imageInfo_Media(const iMedia *d, iMediaId imageId, iGmImageInfo *info_out) {
145 if (imageId > 0 && imageId <= size_PtrArray(&d->images)) { 215 if (imageId > 0 && imageId <= size_PtrArray(&d->images)) {
146 const iGmImage *img = constAt_PtrArray(&d->images, imageId - 1); 216 const iGmImage *img = constAt_PtrArray(&d->images, imageId - 1);
147 info_out->size = img->size; 217 info_out->size = img->size;
148 info_out->numBytes = img->numBytes; 218 info_out->numBytes = img->numBytes;
149 info_out->mime = cstr_String(&img->mime); 219 info_out->mime = cstr_String(&img->props.mime);
150 info_out->isPermanent = img->isPermanent; 220 info_out->isPermanent = img->props.isPermanent;
221 return iTrue;
151 } 222 }
152 else { 223 iZap(*info_out);
153 iZap(*info_out); 224 return iFalse;
225}
226
227iPlayer *audioData_Media(const iMedia *d, iMediaId audioId) {
228 if (audioId > 0 && audioId <= size_PtrArray(&d->audio)) {
229 const iGmAudio *audio = constAt_PtrArray(&d->audio, audioId - 1);
230 return audio->player;
231 }
232 return NULL;
233}
234
235iBool audioInfo_Media(const iMedia *d, iMediaId audioId, iGmAudioInfo *info_out) {
236 if (audioId > 0 && audioId <= size_PtrArray(&d->audio)) {
237 const iGmAudio *audio = constAt_PtrArray(&d->audio, audioId - 1);
238 info_out->mime = cstr_String(&audio->props.mime);
239 info_out->isPermanent = audio->props.isPermanent;
240 return iTrue;
154 } 241 }
242 iZap(*info_out);
243 return iFalse;
155} 244}
diff --git a/src/media.h b/src/media.h
index 0a6c1a81..6f460422 100644
--- a/src/media.h
+++ b/src/media.h
@@ -29,20 +29,32 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
29 29
30typedef uint16_t iMediaId; 30typedef uint16_t iMediaId;
31 31
32iDeclareType(Player)
32iDeclareType(GmImageInfo) 33iDeclareType(GmImageInfo)
34iDeclareType(GmAudioInfo)
33 35
34struct Impl_GmImageInfo { 36struct Impl_GmImageInfo {
35 iInt2 size; 37 iInt2 size;
36 size_t numBytes; 38 size_t numBytes;
37 const char *mime; 39 const char *mime;
38 iBool isPermanent; 40 iBool isPermanent;
39}; 41};
40 42
41iDeclareType(Media) iDeclareTypeConstruction(Media) 43struct Impl_GmAudioInfo {
44 const char *mime;
45 iBool isPermanent;
46};
47
48iDeclareType(Media)
49iDeclareTypeConstruction(Media)
42 50
43void clear_Media (iMedia *); 51void clear_Media (iMedia *);
44void setImage_Media (iMedia *, uint16_t linkId, const iString *mime, const iBlock *data, iBool allowHide); 52void setData_Media (iMedia *, uint16_t linkId, const iString *mime, const iBlock *data, iBool allowHide);
45 53
46iMediaId findLinkImage_Media (const iMedia *, uint16_t linkId); 54iMediaId findLinkImage_Media (const iMedia *, uint16_t linkId);
55iBool imageInfo_Media (const iMedia *, iMediaId imageId, iGmImageInfo *info_out);
47SDL_Texture * imageTexture_Media (const iMedia *, iMediaId imageId); 56SDL_Texture * imageTexture_Media (const iMedia *, iMediaId imageId);
48void imageInfo_Media (const iMedia *, iMediaId imageId, iGmImageInfo *info_out); 57
58iMediaId findLinkAudio_Media (const iMedia *, uint16_t linkId);
59iBool audioInfo_Media (const iMedia *, iMediaId audioId, iGmAudioInfo *info_out);
60iPlayer * audioPlayer_Media (const iMedia *, iMediaId audioId);
diff --git a/src/ui/documentwidget.c b/src/ui/documentwidget.c
index f63d93f4..be9da513 100644
--- a/src/ui/documentwidget.c
+++ b/src/ui/documentwidget.c
@@ -723,24 +723,26 @@ static void updateDocument_DocumentWidget_(iDocumentWidget *d, const iGmResponse
723 docFormat = gemini_GmDocumentFormat; 723 docFormat = gemini_GmDocumentFormat;
724 setRange_String(&d->sourceMime, param); 724 setRange_String(&d->sourceMime, param);
725 } 725 }
726 else if (startsWith_Rangecc(param, "image/")) { 726 else if (startsWith_Rangecc(param, "image/") ||
727 startsWith_Rangecc(param, "audio/")) {
727 docFormat = gemini_GmDocumentFormat; 728 docFormat = gemini_GmDocumentFormat;
728 setRange_String(&d->sourceMime, param); 729 setRange_String(&d->sourceMime, param);
729 if (!d->request || isFinished_GmRequest(d->request)) { 730 if (!d->request || isFinished_GmRequest(d->request)) {
730 /* Make a simple document with an image. */ 731 /* Make a simple document with an image or audio player. */
731 const char *imageTitle = "Image"; 732 const char *linkTitle =
733 startsWith_String(mimeStr, "image/") ? "Image" : "Audio";
732 iUrl parts; 734 iUrl parts;
733 init_Url(&parts, d->mod.url); 735 init_Url(&parts, d->mod.url);
734 if (!isEmpty_Range(&parts.path)) { 736 if (!isEmpty_Range(&parts.path)) {
735 imageTitle = 737 linkTitle =
736 baseName_Path(collect_String(newRange_String(parts.path))).start; 738 baseName_Path(collect_String(newRange_String(parts.path))).start;
737 } 739 }
738 format_String(&str, "=> %s %s\n", cstr_String(d->mod.url), imageTitle); 740 format_String(&str, "=> %s %s\n", cstr_String(d->mod.url), linkTitle);
739 setImage_Media(media_GmDocument(d->doc), 741 setData_Media(media_GmDocument(d->doc),
740 1, 742 1,
741 mimeStr, 743 mimeStr,
742 &response->body, 744 &response->body,
743 iFalse /* it's fixed */); 745 iFalse /* it's fixed */);
744 redoLayout_GmDocument(d->doc); 746 redoLayout_GmDocument(d->doc);
745 } 747 }
746 else { 748 else {
@@ -1132,6 +1134,8 @@ static iBool handleMediaCommand_DocumentWidget_(iDocumentWidget *d, const char *
1132 return iFalse; /* not our request */ 1134 return iFalse; /* not our request */
1133 } 1135 }
1134 if (equal_Command(cmd, "media.updated")) { 1136 if (equal_Command(cmd, "media.updated")) {
1137 /* Pass new data to media players. */
1138
1135 /* Update the link's progress. */ 1139 /* Update the link's progress. */
1136 invalidateLink_DocumentWidget_(d, req->linkId); 1140 invalidateLink_DocumentWidget_(d, req->linkId);
1137 refresh_Widget(d); 1141 refresh_Widget(d);
@@ -1141,12 +1145,13 @@ static iBool handleMediaCommand_DocumentWidget_(iDocumentWidget *d, const char *
1141 const enum iGmStatusCode code = status_GmRequest(req->req); 1145 const enum iGmStatusCode code = status_GmRequest(req->req);
1142 /* Give the media to the document for presentation. */ 1146 /* Give the media to the document for presentation. */
1143 if (code == success_GmStatusCode) { 1147 if (code == success_GmStatusCode) {
1144 if (startsWith_String(meta_GmRequest(req->req), "image/")) { 1148 if (startsWith_String(meta_GmRequest(req->req), "image/") ||
1145 setImage_Media(media_GmDocument(d->doc), 1149 startsWith_String(meta_GmRequest(req->req), "audio/")) {
1146 req->linkId, 1150 setData_Media(media_GmDocument(d->doc),
1147 meta_GmRequest(req->req), 1151 req->linkId,
1148 body_GmRequest(req->req), 1152 meta_GmRequest(req->req),
1149 iTrue); 1153 body_GmRequest(req->req),
1154 iTrue);
1150 redoLayout_GmDocument(d->doc); 1155 redoLayout_GmDocument(d->doc);
1151 updateVisible_DocumentWidget_(d); 1156 updateVisible_DocumentWidget_(d);
1152 invalidate_DocumentWidget_(d); 1157 invalidate_DocumentWidget_(d);
@@ -1793,14 +1798,14 @@ static iBool processEvent_DocumentWidget_(iDocumentWidget *d, const SDL_Event *e
1793 if (isMediaLink_GmDocument(d->doc, linkId)) { 1798 if (isMediaLink_GmDocument(d->doc, linkId)) {
1794 const int linkFlags = linkFlags_GmDocument(d->doc, linkId); 1799 const int linkFlags = linkFlags_GmDocument(d->doc, linkId);
1795 if (linkFlags & content_GmLinkFlag && linkFlags & permanent_GmLinkFlag) { 1800 if (linkFlags & content_GmLinkFlag && linkFlags & permanent_GmLinkFlag) {
1796 /* We have the image and it cannot be dismissed, so nothing 1801 /* We have the content and it cannot be dismissed, so nothing
1797 further to do. */ 1802 further to do. */
1798 return iTrue; 1803 return iTrue;
1799 } 1804 }
1800 if (!requestMedia_DocumentWidget_(d, linkId)) { 1805 if (!requestMedia_DocumentWidget_(d, linkId)) {
1801 if (linkFlags & content_GmLinkFlag) { 1806 if (linkFlags & content_GmLinkFlag) {
1802 /* Dismiss shown content on click. */ 1807 /* Dismiss shown content on click. */
1803 setImage_Media(media_GmDocument(d->doc), linkId, NULL, NULL, iTrue); 1808 setData_Media(media_GmDocument(d->doc), linkId, NULL, NULL, iTrue);
1804 redoLayout_GmDocument(d->doc); 1809 redoLayout_GmDocument(d->doc);
1805 d->hoverLink = NULL; 1810 d->hoverLink = NULL;
1806 scroll_DocumentWidget_(d, 0); 1811 scroll_DocumentWidget_(d, 0);
@@ -1813,11 +1818,11 @@ static iBool processEvent_DocumentWidget_(iDocumentWidget *d, const SDL_Event *e
1813 /* Show the existing content again if we have it. */ 1818 /* Show the existing content again if we have it. */
1814 iMediaRequest *req = findMediaRequest_DocumentWidget_(d, linkId); 1819 iMediaRequest *req = findMediaRequest_DocumentWidget_(d, linkId);
1815 if (req) { 1820 if (req) {
1816 setImage_Media(media_GmDocument(d->doc), 1821 setData_Media(media_GmDocument(d->doc),
1817 linkId, 1822 linkId,
1818 meta_GmRequest(req->req), 1823 meta_GmRequest(req->req),
1819 body_GmRequest(req->req), 1824 body_GmRequest(req->req),
1820 iTrue); 1825 iTrue);
1821 redoLayout_GmDocument(d->doc); 1826 redoLayout_GmDocument(d->doc);
1822 updateVisible_DocumentWidget_(d); 1827 updateVisible_DocumentWidget_(d);
1823 invalidate_DocumentWidget_(d); 1828 invalidate_DocumentWidget_(d);
@@ -1913,6 +1918,11 @@ static void drawRun_DrawContext_(void *context, const iGmRun *run) {
1913 } 1918 }
1914 return; 1919 return;
1915 } 1920 }
1921 else if (run->audioId) {
1922 /* Draw the audio player interface. */
1923 fillRect_Paint(&d->paint, moved_Rect(run->visBounds, origin), red_ColorId);
1924 return;
1925 }
1916 enum iColorId fg = run->color; 1926 enum iColorId fg = run->color;
1917 const iGmDocument *doc = d->widget->doc; 1927 const iGmDocument *doc = d->widget->doc;
1918 const iBool isHover = 1928 const iBool isHover =
@@ -1989,13 +1999,23 @@ static void drawRun_DrawContext_(void *context, const iGmRun *run) {
1989 /* Show metadata about inline content. */ 1999 /* Show metadata about inline content. */
1990 if (flags & content_GmLinkFlag && run->flags & endOfLine_GmRunFlag) { 2000 if (flags & content_GmLinkFlag && run->flags & endOfLine_GmRunFlag) {
1991 fg = linkColor_GmDocument(doc, run->linkId, textHover_GmLinkPart); 2001 fg = linkColor_GmDocument(doc, run->linkId, textHover_GmLinkPart);
1992 iAssert(!isEmpty_Rect(run->bounds));
1993 iGmImageInfo info;
1994 imageInfo_Media(constMedia_GmDocument(doc), linkImage_GmDocument(doc, run->linkId), &info);
1995 iString text; 2002 iString text;
1996 init_String(&text); 2003 init_String(&text);
1997 format_String(&text, "%s \u2014 %d x %d \u2014 %.1fMB", 2004 iMediaId imageId = linkImage_GmDocument(doc, run->linkId);
1998 info.mime, info.size.x, info.size.y, info.numBytes / 1.0e6f); 2005 iMediaId audioId = !imageId ? linkAudio_GmDocument(doc, run->linkId) : 0;
2006 iAssert(imageId || audioId);
2007 if (imageId) {
2008 iAssert(!isEmpty_Rect(run->bounds));
2009 iGmImageInfo info;
2010 imageInfo_Media(constMedia_GmDocument(doc), imageId, &info);
2011 format_String(&text, "%s \u2014 %d x %d \u2014 %.1fMB",
2012 info.mime, info.size.x, info.size.y, info.numBytes / 1.0e6f);
2013 }
2014 else if (audioId) {
2015 iGmAudioInfo info;
2016 audioInfo_Media(constMedia_GmDocument(doc), audioId, &info);
2017 format_String(&text, "%s", info.mime);
2018 }
1999 if (findMediaRequest_DocumentWidget_(d->widget, run->linkId)) { 2019 if (findMediaRequest_DocumentWidget_(d->widget, run->linkId)) {
2000 appendFormat_String( 2020 appendFormat_String(
2001 &text, " %s\u2a2f", isHover ? escape_Color(tmLinkText_ColorId) : ""); 2021 &text, " %s\u2a2f", isHover ? escape_Color(tmLinkText_ColorId) : "");