diff --git a/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/AndroidSoundPlayer.kt b/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/AndroidSoundPlayer.kt new file mode 100644 index 0000000..8f26ded --- /dev/null +++ b/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/AndroidSoundPlayer.kt @@ -0,0 +1,95 @@ +package me.konyaco.collinsdictionary.service + +import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.util.Log +import androidx.core.net.toUri +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.coroutines.* +import java.io.File +import kotlin.coroutines.resume + +private const val TAG = "AndroidSoundPlayer.android" + +class AndroidSoundPlayer : SoundPlayer { + var context: Context? = null + + override fun play( + url: String, + onStart: () -> Unit, + onStop: () -> Unit, + onError: (e: Throwable) -> Unit + ) { + job.let { + job = scope.async(Dispatchers.IO) { + val file = try { + getFile(url) + } catch (e: Throwable) { + Log.w(TAG, "Failed to download sound", e) + onError(e) + return@async + } + it?.cancelAndJoin() + try { + onStart() + playMedia(file) + onStop() + } catch (e: Throwable) { + Log.w("Failed to play sound", e) + onError(e) + } + } + } + } + + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + private suspend fun playMedia(file: File) { + suspendCancellableCoroutine { continuation -> + val mediaPlayer = MediaPlayer() + continuation.invokeOnCancellation { + mediaPlayer.pause() + mediaPlayer.release() + } + with(mediaPlayer) { + setOnCompletionListener { + release() + continuation.resume(Unit) + } + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setDataSource(context!!, file.toUri()) + prepare() + start() + } + } + } + + private fun directory(): File = + context!!.externalCacheDir!!.resolve("sound").also { it.mkdir() } + + // Get filename part of url (xx://xx/xxx.mp3) + private fun fileNameFromUrl(url: String) = url.substringAfterLast("/") + + private suspend fun getFile(url: String): File { + val fileName = fileNameFromUrl(url) + val file = directory().resolve(fileName) + if (!file.exists()) { + val bytes = HttpClient().get(url).readBytes() + withContext(Dispatchers.IO) { + file.outputStream().use { + it.write(bytes) + } + } + } + return file + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.android.kt b/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.android.kt index 5790661..c4a0430 100644 --- a/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.android.kt +++ b/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.android.kt @@ -1,107 +1,14 @@ package me.konyaco.collinsdictionary.service -import android.content.Context -import android.media.AudioAttributes -import android.media.MediaPlayer -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import kotlinx.coroutines.* -import java.io.File -import kotlin.coroutines.resume - -private const val TAG = "SoundPlayer.android" - -class SoundPlayerImpl : SoundPlayer { - var context: Context? = null - - override fun play( - url: String, - onStart: () -> Unit, - onStop: () -> Unit, - onError: (e: Throwable) -> Unit - ) { - job.let { - job = scope.async(Dispatchers.IO) { - val file = try { - getFile(url) - } catch (e: Throwable) { - Log.w(TAG, "Failed to download sound", e) - onError(e) - return@async - } - it?.cancelAndJoin() - try { - onStart() - playMedia(file) - onStop() - } catch (e: Throwable) { - Log.w("Failed to play sound", e) - onError(e) - } - } - } - } - - private val scope = CoroutineScope(Dispatchers.Default) - private var job: Job? = null - - private suspend fun playMedia(file: File) { - suspendCancellableCoroutine { continuation -> - val mediaPlayer = MediaPlayer() - continuation.invokeOnCancellation { - mediaPlayer.pause() - mediaPlayer.release() - } - with(mediaPlayer) { - setOnCompletionListener { - release() - continuation.resume(Unit) - } - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setDataSource(context!!, file.toUri()) - prepare() - start() - } - } - } - - private fun directory(): File = - context!!.externalCacheDir!!.resolve("sound").also { it.mkdir() } - - // Get filename part of url (xx://xx/xxx.mp3) - private fun fileNameFromUrl(url: String) = url.substringAfterLast("/") - - private suspend fun getFile(url: String): File { - val fileName = fileNameFromUrl(url) - val file = directory().resolve(fileName) - if (!file.exists()) { - val bytes = HttpClient().get(url).readBytes() - withContext(Dispatchers.IO) { - file.outputStream().use { - it.write(bytes) - } - } - } - return file - } -} @Composable actual fun getSoundPlayer(): SoundPlayer { val context = LocalContext.current.applicationContext - val soundPlayer = remember { SoundPlayerImpl() } + val soundPlayer = remember { AndroidSoundPlayer() } DisposableEffect(Unit) { soundPlayer.context = context onDispose { soundPlayer.context = null } diff --git a/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt index 09a3e46..b611334 100644 --- a/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt +++ b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt @@ -1,3 +1,22 @@ package me.konyaco.collinsdictionary.service -expect class CollinsOnlineDictionary(): CollinsDictionary \ No newline at end of file +import me.konyaco.collinsdictionary.service.internal.JsoupCollinsParser +import me.konyaco.collinsdictionary.service.internal.CollinsHttpRequester + +class CollinsOnlineDictionary : CollinsDictionary { + private val parser = JsoupCollinsParser() + private val requester = CollinsHttpRequester() + + override suspend fun search(word: String): SearchResult { + return when (val result = requester.search(word)) { + is CollinsHttpRequester.SearchResult.PreciseWord -> SearchResult.PreciseWord(word) + is CollinsHttpRequester.SearchResult.Redirect -> SearchResult.Redirect(result.redirectWord) + is CollinsHttpRequester.SearchResult.NotFound -> SearchResult.NotFound(parser.parseAlternatives(result.wordListHtml)) + } + } + + override suspend fun getDefinition(word: String): Word? { + val html = requester.getDefinition(word) + return parser.parseContent(html) + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/CollinsHttpRequester.kt b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/CollinsHttpRequester.kt new file mode 100644 index 0000000..28347bb --- /dev/null +++ b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/CollinsHttpRequester.kt @@ -0,0 +1,92 @@ +package me.konyaco.collinsdictionary.service.internal + +import io.ktor.client.* +import io.ktor.client.engine.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import java.net.ProxySelector +import java.net.URI + +internal class CollinsHttpRequester { + private val client = HttpClient(CIO) { + setupClient() + followRedirects = false + } + private val clientFollowRedirect = HttpClient(CIO) { + setupClient() + } + + private fun HttpClientConfig<*>.setupClient() { + engine { + val proxy = + ProxySelector.getDefault().select(URI("https://www.collinsdictionary.com")) + .firstOrNull() + if (proxy != null && proxy.type != ProxyType.UNKNOWN) { + this.proxy = proxy + } + } + } + + companion object { + private const val SEARCH_URL = "https://www.collinsdictionary.com/search" + private const val SPELL_CHECK_URL = "https://www.collinsdictionary.com/spellcheck/english" + private const val DICTIONARY_URL = "https://www.collinsdictionary.com/dictionary/english" + private fun buildSearchURL(word: String) = "$SEARCH_URL/?dictCode=english&q=$word" + private fun buildDictionaryURL(word: String) = "$DICTIONARY_URL/$word" + } + + suspend fun getDefinition(word: String): String { + return clientFollowRedirect.get(buildDictionaryURL(word)).bodyAsText() + } + + sealed interface SearchResult { + data class PreciseWord(val contentHtml: String): SearchResult + data class Redirect(val redirectWord: String): SearchResult + data class NotFound(val wordListHtml: String): SearchResult + } + + suspend fun search(word: String): SearchResult { + val response = try { + client.get(buildSearchURL(word)) + } catch (e: RedirectResponseException) { + e.response + } + + if (response.status == HttpStatusCode.Found) { + val redirectedUrl = + response.headers[HttpHeaders.Location] ?: error("Redirect to header was not found.") + + return if (isPrecise(redirectedUrl)) { + val redirectWord = getRedirectedWord(redirectedUrl) + if (redirectWord == word) { + SearchResult.PreciseWord(redirectWord) + } else { + SearchResult.Redirect(redirectWord) + } + } else { + val html = client.get(redirectedUrl).bodyAsText() // Get response in [spellcheck] + SearchResult.NotFound(html) + } + } else { + error("Response code is: ${response.status}") + } + } + + /** + * If the search result redirects to a precise page. Or to a spellcheck page. + */ + private fun isPrecise(redirectedUrl: String): Boolean { + return when { + redirectedUrl.startsWith(DICTIONARY_URL) -> true + redirectedUrl.startsWith(SPELL_CHECK_URL) -> false + else -> error("Could not search word.") + } + } + + private fun getRedirectedWord(redirectedUrl: String): String = + redirectedUrl.substringAfterLast("/") + +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/JsoupCollinsParser.kt similarity index 55% rename from common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt rename to common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/JsoupCollinsParser.kt index b7dfdf7..251538d 100644 --- a/common/src/androidMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt +++ b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/service/internal/JsoupCollinsParser.kt @@ -1,83 +1,13 @@ -// This file is copied from jvmMain source. -package me.konyaco.collinsdictionary.service +package me.konyaco.collinsdictionary.service.internal -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* +import me.konyaco.collinsdictionary.service.* import org.jsoup.Jsoup import org.jsoup.nodes.Element import java.util.* -actual class CollinsOnlineDictionary : CollinsDictionary { - private val client = HttpClient(CIO) { followRedirects = false } - private val clientFollowRedirect = HttpClient(CIO) - - companion object { - private const val SEARCH_URL = "https://www.collinsdictionary.com/search" - private const val SPELL_CHECK_URL = "https://www.collinsdictionary.com/spellcheck/english" - private const val DICTIONARY_URL = "https://www.collinsdictionary.com/dictionary/english" - private fun buildSearchURL(word: String) = "$SEARCH_URL/?dictCode=english&q=$word" - private fun buildDictionaryURL(word: String) = "$DICTIONARY_URL/$word" - } - - override suspend fun getDefinition(word: String): Word? { - return CollinsDictionaryHTMLParser.parse(getHtml(word)) - } - - override suspend fun search(word: String): SearchResult { - val response = try { - client.get(buildSearchURL(word)) - } catch (e: RedirectResponseException) { - e.response - } - - if (response.status == HttpStatusCode.Found) { - val redirectedUrl = - response.headers[HttpHeaders.Location] ?: error("Redirect to header was not found.") - - return if (isPrecise(redirectedUrl)) { - val redirectWord = getRedirectedWord(redirectedUrl) - if (redirectWord == word) { - SearchResult.PreciseWord(redirectWord) - } else { - SearchResult.Redirect(redirectWord) - } - } else { - val html = client.get(redirectedUrl).bodyAsText() // Get response in [spellcheck] - val list = CollinsSpellCheckParser.parseWordList(html) // Parse result list - SearchResult.NotFound(list) - } - } else { - error("Response code is: ${response.status}") - } - } - - /** - * If the search result redirects to a precise page. Or to a spellcheck page. - */ - private fun isPrecise(redirectedUrl: String): Boolean { - return when { - redirectedUrl.startsWith(DICTIONARY_URL) -> true - redirectedUrl.startsWith(SPELL_CHECK_URL) -> false - else -> error("Could not search word.") - } - } - - private fun getRedirectedWord(redirectedUrl: String): String = - redirectedUrl.substringAfterLast("/") - - private suspend fun getHtml(word: String): String { - // Some words may be redirected to another (like "out" -> "out_1") - return clientFollowRedirect.get(buildDictionaryURL(word)).bodyAsText() - } -} - -private object CollinsSpellCheckParser { - fun parseWordList(html: String): List { - val jsoup = Jsoup.parse(html) +internal class JsoupCollinsParser { + fun parseAlternatives(alternativeHtml: String): List { + val jsoup = Jsoup.parse(alternativeHtml) val mainContentElement = jsoup.getElementById("main_content") ?: error("Could not parse word list: main_content not found.") val column = mainContentElement.getElementsByClass("columns2") @@ -87,18 +17,16 @@ private object CollinsSpellCheckParser { } return result } -} -private object CollinsDictionaryHTMLParser { - fun parse(html: String): Word? { - val jsoup = Jsoup.parse(html) + fun parseContent(htmlContent: String): Word? { + val jsoup = Jsoup.parse(htmlContent) val mainContentElement = jsoup.getElementById("main_content") ?: return null // Word not found return Word(parseCobuildDictionary(mainContentElement)) } - fun parseCobuildDictionary(mainContentElement: Element): CobuildDictionary { + private fun parseCobuildDictionary(mainContentElement: Element): CobuildDictionary { return run { val cobuildElement = mainContentElement.getElementsByClass("dictionary Cob_Adv_Brit dictentry") @@ -117,48 +45,17 @@ private object CollinsDictionaryHTMLParser { } } - fun parseSection(dictionaryElement: Element): CobuildDictionarySection { - val wordName = WordNameParser().parse(dictionaryElement) - val wordFrequency = WordFrequencyParser().parse(dictionaryElement) - val wordForms: List? = WordFormParser().parse(dictionaryElement) - val pronunciation = PronunciationParser().parse(dictionaryElement) - val definitionEntries = DefinitionParser().parse(dictionaryElement) - + private fun parseSection(dictionaryElement: Element): CobuildDictionarySection { return CobuildDictionarySection( - word = wordName, - frequency = wordFrequency, - forms = wordForms, - pronunciation = pronunciation, - definitionEntries = definitionEntries + word = parseWordName(dictionaryElement), + frequency = parseWordFrequency(dictionaryElement), + forms = parseWordForms(dictionaryElement), + pronunciation = parsePronunciation(dictionaryElement), + definitionEntries = parseDefinition(dictionaryElement) ) } -} - -private class WordFrequencyParser { - fun parse(dictionaryElement: Element): Int? { - return dictionaryElement.getElementsByClass("word-frequency-img") - .firstOrNull() - ?.attributes() - ?.get("data-band") - ?.toInt() - } -} -private class WordNameParser { - fun parse(dictionaryElement: Element): String { - return dictionaryElement.getElementsByClass("title_container") - .firstOrNull() - ?.getElementsByTag("h2") - ?.lastOrNull() - ?.getElementsByTag("span") - ?.firstOrNull() - ?.text() - ?: error("Cannot find word name") - } -} - -private class WordFormParser { - fun parse(dictionaryElement: Element): List? { + private fun parseWordForms(dictionaryElement: Element): List? { val formElement = dictionaryElement.getElementsByClass("form inflected_forms type-infl") .firstOrNull() ?: return null val result = mutableListOf() @@ -179,10 +76,27 @@ private class WordFormParser { } return result } -} -private class PronunciationParser { - fun parse(dictionaryElement: Element): Pronunciation { + private fun parseWordName(dictionaryElement: Element): String { + return dictionaryElement.getElementsByClass("title_container") + .firstOrNull() + ?.getElementsByTag("h2") + ?.lastOrNull() + ?.getElementsByTag("span") + ?.firstOrNull() + ?.text() + ?: error("Cannot find word name") + } + + private fun parseWordFrequency(dictionaryElement: Element): Int? { + return dictionaryElement.getElementsByClass("word-frequency-img") + .firstOrNull() + ?.attributes() + ?.get("data-band") + ?.toInt() + } + + private fun parsePronunciation(dictionaryElement: Element): Pronunciation { val pronElement = dictionaryElement.getElementsByClass("mini_h2").firstOrNull() ?: error("Cannot find word pronunciation") val pronItem = dictionaryElement.getElementsByClass("pron").firstOrNull() @@ -196,10 +110,8 @@ private class PronunciationParser { } return Pronunciation(pronStr ?: "[err]", sound) } -} -private class DefinitionParser { - fun parse(dictionaryElement: Element): List { + private fun parseDefinition(dictionaryElement: Element): List { val definitionEntries = mutableListOf() val definitionElement = @@ -234,7 +146,7 @@ private class DefinitionParser { definition = Definition( def = def, examples = examples, - synonyms = SynonymParser.parse(senseElement) + synonyms = parseSynonyms(senseElement) ), extraDefinitions = emptyList() // TODO: 2021/7/28 Extra definitions. ) @@ -243,10 +155,8 @@ private class DefinitionParser { return definitionEntries } -} -private object SynonymParser { - fun parse(synonymElement: Element): List? { + private fun parseSynonyms(synonymElement: Element): List? { val result = mutableListOf() val thesElement = synonymElement.getElementsByClass("thes").first() ?: return null diff --git a/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/ui/component/SearchBox.kt b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/ui/component/SearchBox.kt index 71fad6a..53cc77a 100644 --- a/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/ui/component/SearchBox.kt +++ b/common/src/commonMain/kotlin/me/konyaco/collinsdictionary/ui/component/SearchBox.kt @@ -66,7 +66,7 @@ fun SearchBox( imeAction = ImeAction.Search ), textStyle = TextStyle( - color = myColors.onSearchBox.copy(0.6f), + color = myColors.onSearchBox.copy(LocalContentAlpha.current), fontSize = 16.sp, fontWeight = FontWeight.Normal ), diff --git a/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt b/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt deleted file mode 100644 index b261369..0000000 --- a/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/CollinsOnlineDictionary.kt +++ /dev/null @@ -1,275 +0,0 @@ -package me.konyaco.collinsdictionary.service - -import io.ktor.client.* -import io.ktor.client.engine.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import java.net.ProxySelector -import java.net.URI -import java.util.* - -actual class CollinsOnlineDictionary : CollinsDictionary { - private val client = HttpClient(CIO) { - config() - followRedirects = false - } - private val clientFollowRedirect = HttpClient(CIO) { config() } - - private fun HttpClientConfig<*>.config() { - engine { - val proxy = - ProxySelector.getDefault().select(URI("https://www.collinsdictionary.com")) - .firstOrNull() - if (proxy != null && proxy.type != ProxyType.UNKNOWN) { - this.proxy = proxy - } - } - } - - companion object { - private const val SEARCH_URL = "https://www.collinsdictionary.com/search" - private const val SPELL_CHECK_URL = "https://www.collinsdictionary.com/spellcheck/english" - private const val DICTIONARY_URL = "https://www.collinsdictionary.com/dictionary/english" - private fun buildSearchURL(word: String) = "$SEARCH_URL/?dictCode=english&q=$word" - private fun buildDictionaryURL(word: String) = "$DICTIONARY_URL/$word" - } - - override suspend fun getDefinition(word: String): Word? { - return CollinsDictionaryHTMLParser.parse(getHtml(word)) - } - - override suspend fun search(word: String): SearchResult { - val response = try { - client.get(buildSearchURL(word)) - } catch (e: RedirectResponseException) { - e.response - } - - if (response.status == HttpStatusCode.Found) { - val redirectedUrl = - response.headers[HttpHeaders.Location] ?: error("Redirect to header was not found.") - - return if (isPrecise(redirectedUrl)) { - val redirectWord = getRedirectedWord(redirectedUrl) - if (redirectWord == word) { - SearchResult.PreciseWord(redirectWord) - } else { - SearchResult.Redirect(redirectWord) - } - } else { - val html = client.get(redirectedUrl).bodyAsText() // Get response in [spellcheck] - val list = CollinsSpellCheckParser.parseWordList(html) // Parse result list - SearchResult.NotFound(list) - } - } else { - error("Response code is: ${response.status}") - } - } - - /** - * If the search result redirects to a precise page. Or to a spellcheck page. - */ - private fun isPrecise(redirectedUrl: String): Boolean { - return when { - redirectedUrl.startsWith(DICTIONARY_URL) -> true - redirectedUrl.startsWith(SPELL_CHECK_URL) -> false - else -> error("Could not search word.") - } - } - - private fun getRedirectedWord(redirectedUrl: String): String = - redirectedUrl.substringAfterLast("/") - - private suspend fun getHtml(word: String): String { - // Some words may be redirected to another (like "out" -> "out_1") - return clientFollowRedirect.get(buildDictionaryURL(word)).bodyAsText() - } -} - -private object CollinsSpellCheckParser { - fun parseWordList(html: String): List { - val jsoup = Jsoup.parse(html) - val mainContentElement = jsoup.getElementById("main_content") - ?: error("Could not parse word list: main_content not found.") - val column = mainContentElement.getElementsByClass("columns2") - .firstOrNull() ?: error("Could not parse word list: columns2 not found.") - val result = column.children().map { - it.getElementsByTag("a").firstOrNull()?.text() ?: error("Could not get entry") - } - return result - } -} - -private object CollinsDictionaryHTMLParser { - fun parse(html: String): Word? { - val jsoup = Jsoup.parse(html) - val mainContentElement = - jsoup.getElementById("main_content") ?: return null // Word not found - - return Word(parseCobuildDictionary(mainContentElement)) - } - - fun parseCobuildDictionary(mainContentElement: Element): CobuildDictionary { - return run { - val cobuildElement = - mainContentElement.getElementsByClass("dictionary Cob_Adv_Brit dictentry") - .firstOrNull() - cobuildElement?.let { - CobuildDictionary(listOf(parseSection(it))) - } - } ?: run { - val dictionaries = - mainContentElement.getElementsByClass("dictionary Cob_Adv_Brit").firstOrNull() - ?: error("Cannot find COBUILD dictionary element") - // Some words (like "take") have multiple section for different usage. - val sectionElements = dictionaries.getElementsByClass("dictentry dictlink") - val sections = sectionElements.map { parseSection(it) } - CobuildDictionary(sections) - } - } - - fun parseSection(dictionaryElement: Element): CobuildDictionarySection { - val wordName = WordNameParser().parse(dictionaryElement) - val wordFrequency = WordFrequencyParser().parse(dictionaryElement) - val wordForms: List? = WordFormParser().parse(dictionaryElement) - val pronunciation = PronunciationParser().parse(dictionaryElement) - val definitionEntries = DefinitionParser().parse(dictionaryElement) - - return CobuildDictionarySection( - word = wordName, - frequency = wordFrequency, - forms = wordForms, - pronunciation = pronunciation, - definitionEntries = definitionEntries - ) - } -} - -private class WordFrequencyParser { - fun parse(dictionaryElement: Element): Int? { - return dictionaryElement.getElementsByClass("word-frequency-img") - .firstOrNull() - ?.attributes() - ?.get("data-band") - ?.toInt() - } -} - -private class WordNameParser { - fun parse(dictionaryElement: Element): String { - return dictionaryElement.getElementsByClass("title_container") - .firstOrNull() - ?.getElementsByTag("h2") - ?.lastOrNull() - ?.getElementsByTag("span") - ?.firstOrNull() - ?.text() - ?: error("Cannot find word name") - } -} - -private class WordFormParser { - fun parse(dictionaryElement: Element): List? { - val formElement = dictionaryElement.getElementsByClass("form inflected_forms type-infl") - .firstOrNull() ?: return null - val result = mutableListOf() - val types = LinkedList() - for (e in formElement.children()) { - if (e.classNames().contains("type-gram")) { - val type = e.ownText() - // Grammar type - types.push(type) - } else if (e.classNames().contains("orth")) { - // Spell - val spell = e.ownText() - types.forEach { type -> - result.add(WordForm(type, spell)) - } - types.clear() - } - } - return result - } -} - -private class PronunciationParser { - fun parse(dictionaryElement: Element): Pronunciation { - val pronElement = dictionaryElement.getElementsByClass("mini_h2").firstOrNull() - ?: error("Cannot find word pronunciation") - val pronItem = dictionaryElement.getElementsByClass("pron").firstOrNull() - val pronStr = pronItem?.text() - val soundElement = pronElement.getElementsByAttribute("data-src-mp3").firstOrNull() - val sound = soundElement?.let { - it.attributes()["data-src-mp3"] ?: error("Cannot find sound url") - } - if (pronStr == null && soundElement == null) { - error("Cannot find word pronunciation") - } - return Pronunciation(pronStr ?: "[err]", sound) - } -} - -private class DefinitionParser { - fun parse(dictionaryElement: Element): List { - val definitionEntries = mutableListOf() - - val definitionElement = - dictionaryElement.getElementsByClass("content definitions cobuild br ").firstOrNull() - ?: error("Cannot find word definitions") - - definitionElement.getElementsByClass("hom").forEachIndexed { index, element -> - val grammarGroup: String = - element.getElementsByClass("gramGrp pos").firstOrNull()?.text() - ?: element.getElementsByClass("gramGrp").firstOrNull() - ?.getElementsByClass("pos")?.text() - ?: return@forEachIndexed // Maybe it's not a definition, just skip -// ?: error("Cannot find grammar group in entry $index") - - val senseElement = element.getElementsByClass("sense").first()!! - val defElement = senseElement.getElementsByClass("def").first()!! - val def = defElement.text() - - val examples = senseElement.getElementsByClass("cit type-example").map { - val sentence = it.getElementsByClass("quote").first()!!.text() - ExampleSentence( - sentence, - null, - null - ) // TODO: 2021/7/28 Grammar pattern and sound url. - } - - definitionEntries.add( - DefinitionEntry( - index = index + 1, - type = grammarGroup, - definition = Definition( - def = def, - examples = examples, - synonyms = SynonymParser.parse(senseElement) - ), - extraDefinitions = emptyList() // TODO: 2021/7/28 Extra definitions. - ) - ) - } - - return definitionEntries - } -} - -private object SynonymParser { - fun parse(synonymElement: Element): List? { - val result = mutableListOf() - val thesElement = synonymElement.getElementsByClass("thes").first() - ?: return null - - thesElement.getElementsByClass("form ref").forEach { // - result.add(it.text()) - } - return result - } -} \ No newline at end of file diff --git a/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/JavaFxSoundPlayer.kt b/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/JavaFxSoundPlayer.kt new file mode 100644 index 0000000..6b244e5 --- /dev/null +++ b/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/JavaFxSoundPlayer.kt @@ -0,0 +1,84 @@ +package me.konyaco.collinsdictionary.service + +import com.sun.javafx.application.PlatformImpl +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import javafx.scene.media.Media +import javafx.scene.media.MediaPlayer +import kotlinx.coroutines.* +import java.io.File +import kotlin.coroutines.resume + +object JavaFxSoundPlayer : SoundPlayer { + + init { + PlatformImpl.startup { } // In order to use media player. + } + + override fun play( + url: String, + onStart: () -> Unit, + onStop: () -> Unit, + onError: (e: Throwable) -> Unit + ) { + job.let { + job = scope.async(Dispatchers.IO) { + val file = try { + getFile(url) + } catch (e: Throwable) { + System.err.println("Failed to download sound: " + e.stackTraceToString()) + onError(e) + return@async + } + it?.cancelAndJoin() + try { + onStart() + playMedia(file) + onStop() + } catch (e: Throwable) { + System.err.println("Failed to play sound: " + e.stackTraceToString()) + onError(e) + } + } + } + } + + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + private suspend fun playMedia(file: File) { + suspendCancellableCoroutine { continuation -> + val media = Media(file.toURI().toString()) + val mediaPlayer = MediaPlayer(media) + continuation.invokeOnCancellation { + mediaPlayer.stop() + } + with(mediaPlayer) { + setOnEndOfMedia { + continuation.resume(Unit) + } + play() + } + } + } + + private fun directory(): File = File("./cache/music/").also { it.mkdirs() } + + // Get filename part of url (xx://xx/xxx.mp3) + private fun fileNameFromUrl(url: String) = url.substringAfterLast("/") + + private suspend fun getFile(url: String): File { + val fileName = fileNameFromUrl(url) + val file = directory().resolve(fileName) + if (!file.exists()) { + val bytes = HttpClient().get(url).readBytes() + withContext(Dispatchers.IO) { + file.outputStream().use { + it.write(bytes) + } + } + } + return file + } +} \ No newline at end of file diff --git a/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.desktop.kt b/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.desktop.kt index 5bac1b4..e8b69df 100644 --- a/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.desktop.kt +++ b/common/src/jvmMain/kotlin/me/konyaco/collinsdictionary/service/SoundPlayer.desktop.kt @@ -1,91 +1,6 @@ package me.konyaco.collinsdictionary.service import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.sun.javafx.application.PlatformImpl -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import javafx.scene.media.Media -import javafx.scene.media.MediaPlayer -import kotlinx.coroutines.* -import java.io.File -import kotlin.coroutines.resume - -class SoundPlayerImpl : SoundPlayer { - - init { - PlatformImpl.startup { } // In order to use media player. - } - - override fun play( - url: String, - onStart: () -> Unit, - onStop: () -> Unit, - onError: (e: Throwable) -> Unit - ) { - job.let { - job = scope.async(Dispatchers.IO) { - val file = try { - getFile(url) - } catch (e: Throwable) { - System.err.println("Failed to download sound: " + e.stackTraceToString()) - onError(e) - return@async - } - it?.cancelAndJoin() - try { - onStart() - playMedia(file) - onStop() - } catch (e: Throwable) { - System.err.println("Failed to play sound: " + e.stackTraceToString()) - onError(e) - } - } - } - } - - private val scope = CoroutineScope(Dispatchers.Default) - private var job: Job? = null - - private suspend fun playMedia(file: File) { - suspendCancellableCoroutine { continuation -> - val media = Media(file.toURI().toString()) - val mediaPlayer = MediaPlayer(media) - continuation.invokeOnCancellation { - mediaPlayer.stop() - } - with(mediaPlayer) { - setOnEndOfMedia { - continuation.resume(Unit) - } - play() - } - } - } - - private fun directory(): File = File("./cache/music/").also { it.mkdirs() } - - // Get filename part of url (xx://xx/xxx.mp3) - private fun fileNameFromUrl(url: String) = url.substringAfterLast("/") - - private suspend fun getFile(url: String): File { - val fileName = fileNameFromUrl(url) - val file = directory().resolve(fileName) - if (!file.exists()) { - val bytes = HttpClient().get(url).readBytes() - withContext(Dispatchers.IO) { - file.outputStream().use { - it.write(bytes) - } - } - } - return file - } -} @Composable -actual fun getSoundPlayer(): SoundPlayer = remember { - SoundPlayerImpl() -} \ No newline at end of file +actual fun getSoundPlayer(): SoundPlayer = JavaFxSoundPlayer \ No newline at end of file