Initial commit

This commit is contained in:
Michatec
2025-04-27 15:07:05 +02:00
commit 2162c9fb40
157 changed files with 12179 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
/*
* DirectInputCheck.kt
* Implements the DirectInputCheck class
* A DirectInputCheck checks if a station url is valid and returns station via a listener
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-23 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio.search
import android.content.Context
import android.webkit.URLUtil
import android.widget.Toast
import com.michatec.radio.R
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.CollectionHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.GregorianCalendar
data class IcecastMetadata(
val title: String?
)
/*
* DirectInputCheck class
*/
class DirectInputCheck(private var directInputCheckListener: DirectInputCheckListener) {
/* Interface used to send back station list for checked */
interface DirectInputCheckListener {
fun onDirectInputCheck(stationList: MutableList<Station>) {
}
}
/* Main class variables */
private var lastCheckedAddress: String = String()
/* Searches station(s) on radio-browser.info */
fun checkStationAddress(context: Context, query: String) {
// check if valid URL
if (URLUtil.isValidUrl(query)) {
val stationList: MutableList<Station> = mutableListOf()
CoroutineScope(IO).launch {
stationList.addAll(CollectionHelper.createStationsFromUrl(query, lastCheckedAddress))
lastCheckedAddress = query
withContext(Main) {
if (stationList.isNotEmpty()) {
// hand over station is to listener
directInputCheckListener.onDirectInputCheck(stationList)
} else {
// invalid address
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show()
}
}
}
}
}
private suspend fun extractIcecastMetadata(streamUri: String): IcecastMetadata {
return withContext(IO) {
// make an HTTP request at the stream URL to get Icecast metadata.
val client = OkHttpClient()
val request = Request.Builder()
.url(streamUri)
.build()
val response = client.newCall(request).execute()
val icecastHeaders = response.headers
// analyze the Icecast metadata and extract information like title, description, bitrate, etc.
val title = icecastHeaders["icy-name"]
IcecastMetadata(title?.takeIf { it.isNotEmpty() } ?: streamUri)
}
}
private suspend fun updateStationWithIcecastMetadata(station: Station, icecastMetadata: IcecastMetadata) {
withContext(Dispatchers.Default) {
station.name = icecastMetadata.title.toString()
}
}
suspend fun processIcecastStream(streamUri: String, stationList: MutableList<Station>) {
val icecastMetadata = extractIcecastMetadata(streamUri)
val station = Station(name = icecastMetadata.title.toString(), streamUris = mutableListOf(streamUri), modificationDate = GregorianCalendar.getInstance().time)
updateStationWithIcecastMetadata(station, icecastMetadata)
// create station and add to collection
if (lastCheckedAddress != streamUri) {
stationList.add(station)
}
lastCheckedAddress = streamUri
}
}

View File

@@ -0,0 +1,92 @@
/*
* RadioBrowserResult.kt
* Implements the RadioBrowserResult class
* A RadioBrowserResult is the search result of a request to api.radio-browser.info
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio.search
import com.google.gson.annotations.Expose
import com.michatec.radio.Keys
import com.michatec.radio.core.Station
import java.util.*
/*
* RadioBrowserResult class
*/
data class RadioBrowserResult(
@Expose val changeuuid: String,
@Expose val stationuuid: String,
@Expose val name: String,
@Expose val url: String,
@Expose val url_resolved: String,
@Expose val homepage: String,
@Expose val favicon: String,
@Expose val bitrate: Int,
@Expose val codec: String
) {
/* Converts RadioBrowserResult to Station */
fun toStation(): Station = Station(
starred = false,
name = name,
nameManuallySet = false,
streamUris = mutableListOf(url_resolved),
stream = 0,
streamContent = Keys.MIME_TYPE_UNSUPPORTED,
homepage = homepage,
image = String(),
smallImage = String(),
imageColor = -1,
imageManuallySet = false,
remoteImageLocation = favicon,
remoteStationLocation = url,
modificationDate = GregorianCalendar.getInstance().time,
isPlaying = false,
radioBrowserStationUuid = stationuuid,
radioBrowserChangeUuid = changeuuid,
bitrate = bitrate,
codec = codec
)
}
/*
JSON Struct Station
https://de1.api.radio-browser.info/
changeuuid UUID A globally unique identifier for the change of the station information
stationuuid UUID A globally unique identifier for the station
name string The name of the station
url string, URL (HTTP/HTTPS) The stream URL provided by the user
url_resolved string, URL (HTTP/HTTPS) An automatically "resolved" stream URL. Things resolved are playlists (M3U/PLS/ASX...), HTTP redirects (Code 301/302). This link is especially usefull if you use this API from a platform that is not able to do a resolve on its own (e.g. JavaScript in browser) or you just don't want to invest the time in decoding playlists yourself.
homepage string, URL (HTTP/HTTPS) URL to the homepage of the stream, so you can direct the user to a page with more information about the stream.
favicon string, URL (HTTP/HTTPS) URL to an icon or picture that represents the stream. (PNG, JPG)
tags string, multivalue, split by comma Tags of the stream with more information about it
country string DEPRECATED: use countrycode instead, full name of the country
countrycode 2 letters, uppercase Official countrycodes as in ISO 3166-1 alpha-2
state string Full name of the entity where the station is located inside the country
language string, multivalue, split by comma Languages that are spoken in this stream.
votes number, integer Number of votes for this station. This number is by server and only ever increases. It will never be reset to 0.
lastchangetime datetime, YYYY-MM-DD HH:mm:ss Last time when the stream information was changed in the database
codec string The codec of this stream recorded at the last check.
bitrate number, integer, bps The bitrate of this stream recorded at the last check.
hls 0 or 1 Mark if this stream is using HLS distribution or non-HLS.
lastcheckok 0 or 1 The current online/offline state of this stream. This is a value calculated from multiple measure points in the internet. The test servers are located in different countries. It is a majority vote.
lastchecktime datetime, YYYY-MM-DD HH:mm:ss The last time when any radio-browser server checked the online state of this stream
lastcheckoktime datetime, YYYY-MM-DD HH:mm:ss The last time when the stream was checked for the online status with a positive result
lastlocalchecktime datetime, YYYY-MM-DD HH:mm:ss The last time when this server checked the online state and the metadata of this stream
clicktimestamp datetime, YYYY-MM-DD HH:mm:ss The time of the last click recorded for this stream
clickcount number, integer Clicks within the last 24 hours
clicktrend number, integer The difference of the clickcounts within the last 2 days. Posivite values mean an increase, negative a decrease of clicks.
*/

View File

@@ -0,0 +1,144 @@
/*
* RadioBrowserSearch.kt
* Implements the RadioBrowserSearch class
* A RadioBrowserSearch performs searches on the radio-browser.info database
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio.search
import android.content.Context
import android.util.Log
import com.android.volley.*
import com.android.volley.toolbox.JsonArrayRequest
import com.android.volley.toolbox.Volley
import com.google.gson.GsonBuilder
import org.json.JSONArray
import com.michatec.radio.BuildConfig
import com.michatec.radio.Keys
import com.michatec.radio.helpers.NetworkHelper
import com.michatec.radio.helpers.PreferencesHelper
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
/*
* RadioBrowserSearch class
*/
class RadioBrowserSearch(private var radioBrowserSearchListener: RadioBrowserSearchListener) {
/* Define log tag */
private val TAG: String = RadioBrowserSearch::class.java.simpleName
/* Interface used to send back search results */
interface RadioBrowserSearchListener {
fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
}
}
/* Main class variables */
private var radioBrowserApi: String
private lateinit var requestQueue: RequestQueue
/* Init constructor */
init {
// get address of radio-browser.info api and update it in background
radioBrowserApi = PreferencesHelper.loadRadioBrowserApiAddress()
updateRadioBrowserApi()
}
/* Searches station(s) on radio-browser.info */
fun searchStation(context: Context, query: String, searchType: Int) {
Log.v(TAG, "Search - Querying $radioBrowserApi for: $query")
// create queue and request
requestQueue = Volley.newRequestQueue(context)
val requestUrl: String = when (searchType) {
// CASE: single station search - by radio browser UUID
Keys.SEARCH_TYPE_BY_UUID -> "https://${radioBrowserApi}/json/stations/byuuid/${query}"
// CASE: multiple results search by search term
else -> "https://${radioBrowserApi}/json/stations/search?name=${query.replace(" ", "+")}"
}
// request data from request URL
val stringRequest = object: JsonArrayRequest(Method.GET, requestUrl, null, responseListener, errorListener) {
@Throws(AuthFailureError::class)
override fun getHeaders(): Map<String, String> {
val params = HashMap<String, String>()
params["User-Agent"] = "$Keys.APPLICATION_NAME ${BuildConfig.VERSION_NAME}"
return params
}
}
// override retry policy
stringRequest.retryPolicy = object : RetryPolicy {
override fun getCurrentTimeout(): Int {
return 30000
}
override fun getCurrentRetryCount(): Int {
return 30000
}
@Throws(VolleyError::class)
override fun retry(error: VolleyError) {
Log.w(TAG, "Error: $error")
}
}
// add to RequestQueue.
requestQueue.add(stringRequest)
}
fun stopSearchRequest() {
if (this::requestQueue.isInitialized) {
requestQueue.stop()
}
}
/* Converts search result JSON string */
private fun createRadioBrowserResult(result: String): Array<RadioBrowserResult> {
val gsonBuilder = GsonBuilder()
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
val gson = gsonBuilder.create()
return gson.fromJson(result, Array<RadioBrowserResult>::class.java)
}
/* Updates the address of the radio-browser.info api */
private fun updateRadioBrowserApi() {
CoroutineScope(IO).launch {
val deferred: Deferred<String> = async { NetworkHelper.getRadioBrowserServerSuspended() }
radioBrowserApi = deferred.await()
}
}
/* Listens for (positive) server responses to search requests */
private val responseListener: Response.Listener<JSONArray> = Response.Listener<JSONArray> { response ->
if (response != null) {
radioBrowserSearchListener.onRadioBrowserSearchResults(createRadioBrowserResult(response.toString()))
}
}
/* Listens for error response from server */
private val errorListener: Response.ErrorListener = Response.ErrorListener { error ->
Log.w(TAG, "Error: $error")
}
}

View File

@@ -0,0 +1,256 @@
/*
* SearchResultAdapter.kt
* Implements the SearchResultAdapter class
* A SearchResultAdapter is a custom adapter providing search result views for a RecyclerView
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio.search
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import com.michatec.radio.R
import com.michatec.radio.core.Station
/*
* SearchResultAdapter class
*/
class SearchResultAdapter(
private val listener: SearchResultAdapterListener,
var searchResults: List<Station>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/* Main class variables */
private var selectedPosition: Int = RecyclerView.NO_POSITION
private var exoPlayer: ExoPlayer? = null
private var paused: Boolean = false
private var isItemSelected: Boolean = false
/* Listener Interface */
interface SearchResultAdapterListener {
fun onSearchResultTapped(result: Station)
fun activateAddButton()
fun deactivateAddButton()
}
init {
setHasStableIds(true)
}
/* Overrides onCreateViewHolder from RecyclerView.Adapter */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.element_search_result, parent, false)
return SearchResultViewHolder(v)
}
/* Overrides getItemCount from RecyclerView.Adapter */
override fun getItemCount(): Int {
return searchResults.size
}
/* Overrides getItemCount from RecyclerView.Adapter */
override fun getItemId(position: Int): Long = position.toLong()
/* Overrides onBindViewHolder from RecyclerView.Adapter */
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
// get reference to ViewHolder
val searchResultViewHolder: SearchResultViewHolder = holder as SearchResultViewHolder
val searchResult: Station = searchResults[position]
// update text
searchResultViewHolder.nameView.text = searchResult.name
searchResultViewHolder.streamView.text = searchResult.getStreamUri()
if (searchResult.codec.isNotEmpty()) {
if (searchResult.bitrate == 0) {
// show only the codec when the bitrate is at "0" from radio-browser.info API
searchResultViewHolder.bitrateView.text = searchResult.codec
} else {
// show the bitrate and codec if the result is available in the radio-browser.info API
searchResultViewHolder.bitrateView.text = buildString {
append(searchResult.codec)
append(" | ")
append(searchResult.bitrate)
append("kbps")}
}
} else {
// do not show for M3U and PLS playlists as they do not include codec or bitrate
searchResultViewHolder.bitrateView.visibility = View.GONE
}
// mark selected if necessary
val isSelected = selectedPosition == holder.adapterPosition
searchResultViewHolder.searchResultLayout.isSelected = isSelected
// toggle text scrolling (marquee) if necessary
searchResultViewHolder.nameView.isSelected = isSelected
searchResultViewHolder.streamView.isSelected = isSelected
// reduce the shadow left and right because of scrolling (Marquee)
searchResultViewHolder.nameView.setFadingEdgeLength(10)
searchResultViewHolder.streamView.setFadingEdgeLength(10)
// attach touch listener
searchResultViewHolder.searchResultLayout.setOnClickListener {
// move marked position
val previousSelectedPosition = selectedPosition
selectedPosition = holder.adapterPosition
notifyItemChanged(previousSelectedPosition)
notifyItemChanged(selectedPosition)
// check if the selected position is the same as before
val samePositionSelected = previousSelectedPosition == selectedPosition
if (samePositionSelected) {
// if the same position is selected again, reset the selection
resetSelection(false)
} else {
// get the selected station from searchResults
val selectedStation = searchResults[holder.adapterPosition]
// perform pre-playback here
performPrePlayback(searchResultViewHolder.searchResultLayout.context, selectedStation.getStreamUri())
// hand over station
listener.onSearchResultTapped(searchResult)
}
// update isItemSelected based on the selection
isItemSelected = !samePositionSelected
// enable/disable the Add button based on isItemSelected
if (isItemSelected) {
listener.activateAddButton()
} else {
listener.deactivateAddButton()
}
}
}
private fun performPrePlayback(context: Context, streamUri: String) {
if (streamUri.contains(".m3u8")) {
// release previous player if it exists
stopPrePlayback()
// show toast when no playback is possible
Toast.makeText(context, R.string.toastmessage_preview_playback_failed, Toast.LENGTH_SHORT).show()
} else {
stopRadioPlayback(context)
// release previous player if it exists
stopPrePlayback()
// create a new instance of ExoPlayer
exoPlayer = ExoPlayer.Builder(context).build()
// create a MediaItem with the streamUri
val mediaItem = MediaItem.fromUri(streamUri)
// set the MediaItem to the ExoPlayer
exoPlayer?.setMediaItem(mediaItem)
// prepare and start the ExoPlayer
exoPlayer?.prepare()
exoPlayer?.play()
// show toast when playback is possible
Toast.makeText(context, R.string.toastmessage_preview_playback_started, Toast.LENGTH_SHORT).show()
// listen for app pause events
val lifecycle = (context as AppCompatActivity).lifecycle
val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
if (!paused) {
paused = true
stopPrePlayback()
}
}
}
lifecycle.addObserver(lifecycleObserver)
}
}
fun stopPrePlayback() {
// stop the ExoPlayer and release resources
exoPlayer?.stop()
exoPlayer?.release()
exoPlayer = null
}
private fun stopRadioPlayback(context: Context) {
// stop radio playback when one is active
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(audioAttributes)
.build()
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
// For older versions where AudioFocusRequest is not available
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
}
}
/* Resets the selected position */
fun resetSelection(clearAdapter: Boolean) {
val currentlySelected: Int = selectedPosition
selectedPosition = RecyclerView.NO_POSITION
if (clearAdapter) {
val previousItemCount = itemCount
searchResults = emptyList()
notifyItemRangeRemoved(0, previousItemCount)
} else {
notifyItemChanged(currentlySelected)
stopPrePlayback()
}
}
/*
* Inner class: ViewHolder for a radio station search result
*/
private inner class SearchResultViewHolder(var searchResultLayout: View) :
RecyclerView.ViewHolder(searchResultLayout) {
val nameView: MaterialTextView = searchResultLayout.findViewById(R.id.station_name)
val streamView: MaterialTextView = searchResultLayout.findViewById(R.id.station_url)
val bitrateView: MaterialTextView = searchResultLayout.findViewById(R.id.station_bitrate)
}
}