Files
Radio/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
2026-01-21 17:10:35 +01:00

748 lines
29 KiB
Kotlin

/*
* CollectionHelper.kt
* Implements the CollectionHelper object
* A CollectionHelper provides helper methods for the collection of stations
*
* 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.helpers
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.michatec.radio.Keys
import com.michatec.radio.R
import com.michatec.radio.core.Collection
import com.michatec.radio.core.Station
import com.michatec.radio.search.DirectInputCheck
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import java.io.File
import java.net.URL
import java.util.*
/*
* CollectionHelper object
*/
object CollectionHelper {
/* Define log tag */
private val TAG: String = CollectionHelper::class.java.simpleName
/* Checks if station is already in collection */
private fun isNewStation(collection: Collection, station: Station): Boolean {
collection.stations.forEach {
if (it.getStreamUri() == station.getStreamUri()) return false
}
return true
}
/* Checks if station is already in collection */
fun isNewStation(collection: Collection, remoteStationLocation: String): Boolean {
collection.stations.forEach {
if (it.remoteStationLocation == remoteStationLocation) return false
}
return true
}
/* Checks if a newer collection of radio stations is available on storage */
fun isNewerCollectionAvailable(date: Date): Boolean {
var newerCollectionAvailable = false
val modificationDate: Date = PreferencesHelper.loadCollectionModificationDate()
if (modificationDate.after(date) || date == Keys.DEFAULT_DATE) {
newerCollectionAvailable = true
}
return newerCollectionAvailable
}
/* Creates station from previously downloaded playlist file */
fun createStationFromPlaylistFile(
context: Context,
localFileUri: Uri,
remoteFileLocation: String
): Station {
// read station playlist
val station: Station =
FileHelper.readStationPlaylist(context.contentResolver.openInputStream(localFileUri))
if (station.name.isEmpty()) {
// construct name from file name - strips file extension
station.name = FileHelper.getFileName(context, localFileUri).substringBeforeLast(".")
}
station.remoteStationLocation = remoteFileLocation
station.remoteImageLocation = getFaviconAddress(remoteFileLocation)
station.modificationDate = GregorianCalendar.getInstance().time
return station
}
/* Updates radio station in collection */
fun updateStation(context: Context, collection: Collection, station: Station): Collection {
var updatedCollection: Collection = collection
// CASE: Update station retrieved from radio browser
if (station.radioBrowserStationUuid.isNotEmpty()) {
updatedCollection.stations.forEach {
if (it.radioBrowserStationUuid == station.radioBrowserStationUuid) {
// update station in collection with values from new station
it.streamUris[it.stream] = station.getStreamUri()
it.streamContent = station.streamContent
it.remoteImageLocation = station.remoteImageLocation
it.remoteStationLocation = station.remoteStationLocation
it.homepage = station.homepage
// update name - if not changed previously by user
if (!it.nameManuallySet) it.name = station.name
// re-download station image - if new URL and not changed previously by user
DownloadHelper.updateStationImage(context, it)
}
}
// sort and save collection
updatedCollection = sortCollection(updatedCollection)
saveCollection(context, updatedCollection, false)
}
// CASE: Update station retrieved via playlist
else if (station.remoteStationLocation.isNotEmpty()) {
updatedCollection.stations.forEach {
if (it.remoteStationLocation == station.remoteStationLocation) {
// update stream uri, mime type and station image url
it.streamUris[it.stream] = station.getStreamUri()
it.streamContent = station.streamContent
it.remoteImageLocation = station.remoteImageLocation
// update name - if not changed previously by user
if (!it.nameManuallySet) it.name = station.name
// re-download station image - if not changed previously by user
if (!it.imageManuallySet) DownloadHelper.updateStationImage(context, it)
}
}
// sort and save collection
updatedCollection = sortCollection(updatedCollection)
saveCollection(context, updatedCollection, false)
}
return updatedCollection
}
/* Adds new radio station to collection */
fun addStation(context: Context, collection: Collection, newStation: Station): Collection {
// check validity
if (!newStation.isValid()) {
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG)
.show()
return collection
}
// duplicate check
else if (!isNewStation(collection, newStation)) {
// update station
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, R.string.toastmessage_station_duplicate, Toast.LENGTH_LONG)
.show()
}
return collection
}
// all clear -> add station
else {
var updatedCollection: Collection = collection
val updatedStationList: MutableList<Station> = collection.stations.toMutableList()
// add station
updatedStationList.add(newStation)
updatedCollection.stations = updatedStationList
// sort and save collection
updatedCollection = sortCollection(updatedCollection)
saveCollection(context, updatedCollection, false)
// download station image
DownloadHelper.updateStationImage(context, newStation)
// return updated collection
return updatedCollection
}
}
/* Sets station image - determines station by remote image file location */
fun setStationImageWithRemoteLocation(
context: Context,
collection: Collection,
tempImageFileUri: String,
remoteFileLocation: String,
imageManuallySet: Boolean = false
): Collection {
collection.stations.forEach { station ->
// compare image location protocol-agnostic (= without http / https)
if (station.remoteImageLocation.substringAfter(":") == remoteFileLocation.substringAfter(
":"
)
) {
station.smallImage = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri.toUri(),
Keys.SIZE_STATION_IMAGE_CARD,
Keys.STATION_IMAGE_FILE
).toString()
station.image = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri.toUri(),
Keys.SIZE_STATION_IMAGE_MAXIMUM,
Keys.STATION_IMAGE_FILE
).toString()
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri.toUri())
station.imageManuallySet = imageManuallySet
}
}
// save and return collection
saveCollection(context, collection)
return collection
}
/* Sets station image - determines station by remote image file location */
fun setStationImageWithStationUuid(
context: Context,
collection: Collection,
tempImageFileUri: Uri,
stationUuid: String,
imageManuallySet: Boolean = false
): Collection {
collection.stations.forEach { station ->
// find station by uuid
if (station.uuid == stationUuid) {
station.smallImage = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri,
Keys.SIZE_STATION_IMAGE_CARD,
Keys.STATION_IMAGE_FILE
).toString()
station.image = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri,
Keys.SIZE_STATION_IMAGE_MAXIMUM,
Keys.STATION_IMAGE_FILE
).toString()
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri)
station.imageManuallySet = imageManuallySet
}
}
// save and return collection
saveCollection(context, collection)
return collection
}
/* Clears an image folder for a given station */
fun clearImagesFolder(context: Context, station: Station) {
// clear image folder
val imagesFolder = File(
context.getExternalFilesDir(""),
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
)
FileHelper.clearFolder(imagesFolder, 0)
}
/* Deletes Images of a given station */
fun deleteStationImages(context: Context, station: Station) {
val imagesFolder = File(
context.getExternalFilesDir(""),
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
)
FileHelper.clearFolder(imagesFolder, 0, true)
}
/* Get station from collection for given UUID */
fun getStation(collection: Collection, stationUuid: String): Station {
collection.stations.forEach { station ->
if (station.uuid == stationUuid) {
return station
}
}
// fallback: return first station
return if (collection.stations.isNotEmpty()) {
collection.stations.first()
} else {
Station()
}
}
/* Get the position from collection for given UUID */
fun getStationPosition(collection: Collection, stationUuid: String): Int {
collection.stations.forEachIndexed { stationId, station ->
if (station.uuid == stationUuid) {
return stationId
}
}
return -1
}
/* Get the position from collection for given radioBrowserStationUuid */
fun getStationPositionFromRadioBrowserStationUuid(
collection: Collection,
radioBrowserStationUuid: String
): Int {
collection.stations.forEachIndexed { stationId, station ->
if (station.radioBrowserStationUuid == radioBrowserStationUuid) {
return stationId
}
}
return -1
}
/* Returns the children stations under under root (simple media library structure: root > stations) */
fun getChildren(context: Context, collection: Collection): List<MediaItem> {
val mediaItems: MutableList<MediaItem> = mutableListOf()
collection.stations.forEach { station ->
mediaItems.add(buildMediaItem(context, station))
}
return mediaItems
}
/* Returns media item for given station id */
fun getItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
return buildMediaItem(context, getStation(collection, stationUuid))
}
/* Returns media item for last played station */
fun getRecent(context: Context, collection: Collection): MediaItem {
return buildMediaItem(context, getStation(collection, PreferencesHelper.loadLastPlayedStationUuid()))
}
/* Returns the library root item */
fun getRootItem(): MediaItem {
val metadata: MediaMetadata = MediaMetadata.Builder()
.setTitle("Root Folder")
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
return MediaItem.Builder()
.setMediaId("[rootID]")
.setMediaMetadata(metadata)
.build()
}
/* Saves the playback state of a given station */
fun savePlaybackState(
context: Context,
collection: Collection,
stationUuid: String,
isPlaying: Boolean
): Collection {
collection.stations.forEach {
// reset playback state everywhere
it.isPlaying = false
// set given playback state at this station
if (it.uuid == stationUuid) {
it.isPlaying = isPlaying
}
}
// save collection and store modification date
collection.modificationDate = saveCollection(context, collection)
return collection
}
/* Saves collection of radio stations */
fun saveCollection(context: Context, collection: Collection, async: Boolean = true): Date {
Log.v(
TAG,
"Saving collection of radio stations to storage. Async = ${async}. Size = ${collection.stations.size}"
)
// get modification date
val date: Date = Calendar.getInstance().time
collection.modificationDate = date
// save collection to storage
when (async) {
true -> {
CoroutineScope(IO).launch {
// save collection on background thread
FileHelper.saveCollectionSuspended(context, collection, date)
// broadcast collection update
sendCollectionBroadcast(context, date)
}
}
false -> {
// save collection
FileHelper.saveCollection(context, collection, date)
// broadcast collection update
sendCollectionBroadcast(context, date)
}
}
// return modification date
return date
}
/* Creates station from playlist URLs and stream address URLs */
suspend fun createStationsFromUrl(query: String, lastCheckedAddress: String = String()): List<Station> {
val stationList: MutableList<Station> = mutableListOf()
val contentType: String = NetworkHelper.detectContentType(query).type.lowercase(Locale.getDefault())
val directInputCheck: DirectInputCheck? = null
// CASE: M3U playlist detected
if (Keys.MIME_TYPES_M3U.contains(contentType)) {
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
stationList.addAll(readM3uPlaylistContent(lines))
}
// CASE: PLS playlist detected
else if (Keys.MIME_TYPES_PLS.contains(contentType)) {
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
stationList.addAll(readPlsPlaylistContent(lines))
}
// CASE: stream address detected
else if (Keys.MIME_TYPES_MPEG.contains(contentType) or
Keys.MIME_TYPES_OGG.contains(contentType) or
Keys.MIME_TYPES_AAC.contains(contentType) or
Keys.MIME_TYPES_HLS.contains(contentType)) {
// process Icecast stream and extract metadata
directInputCheck?.processIcecastStream(query, stationList)
// create station and add to collection
val station = Station(name = query, streamUris = mutableListOf(query), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
if (lastCheckedAddress != query) {
stationList.add(station)
}
}
return stationList
}
/* Creates station from URI pointing to a local file */
fun createStationListFromContentUri(context: Context, contentUri: Uri): List<Station> {
val stationList: MutableList<Station> = mutableListOf()
val fileType: String = FileHelper.getContentType(context, contentUri)
// CASE: M3U playlist detected
if (Keys.MIME_TYPES_M3U.contains(fileType)) {
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
stationList.addAll(readM3uPlaylistContent(playlist))
}
// CASE: PLS playlist detected
else if (Keys.MIME_TYPES_PLS.contains(fileType)) {
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
stationList.addAll(readPlsPlaylistContent(playlist))
}
return stationList
}
/* Reads a m3u playlist and returns a list of stations */
private fun readM3uPlaylistContent(playlist: List<String>): List<Station> {
val stations: MutableList<Station> = mutableListOf()
var name = String()
var streamUri: String
var contentType: String
playlist.forEach { line ->
// get name of station
if (line.startsWith("#EXTINF:")) {
name = line.substringAfter(",").trim()
}
// get stream uri and check mime type
else if (line.isNotBlank() && !line.startsWith("#")) {
streamUri = line.trim()
// use the stream address as the name if no name is specified
if (name.isEmpty()) {
name = streamUri
}
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
// store station in list if mime type is supported
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
stations.add(station)
}
// reset name for the next station - useful if playlist does not provide name(s)
name = String()
}
}
return stations
}
/* Reads a pls playlist and returns a list of stations */
private fun readPlsPlaylistContent(playlist: List<String>): List<Station> {
val stations: MutableList<Station> = mutableListOf()
var name = String()
var streamUri: String
var contentType: String
playlist.forEachIndexed { index, line ->
// get stream uri and check mime type
if (line.startsWith("File")) {
streamUri = line.substringAfter("=").trim()
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
// look for the matching station name
val number: String = line.substring(4 /* File */, line.indexOf("="))
val lineBeforeIndex: Int = index - 1
val lineAfterIndex: Int = index + 1
// first: check the line before
if (lineBeforeIndex >= 0) {
val lineBefore: String = playlist[lineBeforeIndex]
if (lineBefore.startsWith("Title$number")) {
name = lineBefore.substringAfter("=").trim()
}
}
// then: check the line after
if (name.isEmpty() && lineAfterIndex < playlist.size) {
val lineAfter: String = playlist[lineAfterIndex]
if (lineAfter.startsWith("Title$number")) {
name = lineAfter.substringAfter("=").trim()
}
}
// fallback: use stream uri as name
if (name.isEmpty()) {
name = streamUri
}
// add station
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
stations.add(station)
}
}
}
return stations
}
/* Export collection of stations as M3U */
fun exportCollectionM3u(context: Context, collection: Collection) {
Log.v(TAG, "Exporting collection of stations as M3U")
// export collection as M3U - launch = fire & forget (no return value from save collection)
if (collection.stations.isNotEmpty()) {
CoroutineScope(IO).launch {
FileHelper.backupCollectionAsM3uSuspended(
context,
collection
)
}
}
}
/* Create M3U string from collection of stations */
fun createM3uString(collection: Collection): String {
val m3uString = StringBuilder()
/* Extended M3U Format
#EXTM3U
#EXTINF:-1,My Cool Stream
http://www.site.com:8000/listen.pls
*/
// add opening tag
m3uString.append("#EXTM3U")
m3uString.append("\n")
// add name and stream address
collection.stations.forEach { station ->
m3uString.append("\n")
m3uString.append("#EXTINF:-1,")
m3uString.append(station.name)
m3uString.append("\n")
m3uString.append(station.getStreamUri())
m3uString.append("\n")
}
return m3uString.toString()
}
/* Export collection of stations as PLS */
fun exportCollectionPls(context: Context, collection: Collection) {
Log.v(TAG, "Exporting collection of stations as PLS")
// export collection as PLS - launch = fire & forget (no return value from save collection)
if (collection.stations.isNotEmpty()) {
CoroutineScope(IO).launch {
FileHelper.backupCollectionAsPlsSuspended(
context,
collection
)
}
}
}
/* Create PLS string from collection of stations */
fun createPlsString(collection: Collection): String {
/* Extended PLS Format
[playlist]
Title1=My Cool Stream
File1=http://www.site.com:8000/listen.pls
Length1=-1
NumberOfEntries=1
Version=2
*/
val plsString = StringBuilder()
var counter = 1
// add opening tag
plsString.append("[playlist]")
plsString.append("\n")
// add name and stream address
collection.stations.forEach { station ->
plsString.append("\n")
plsString.append("Title$counter=")
plsString.append(station.name)
plsString.append("\n")
plsString.append("File$counter=")
plsString.append(station.getStreamUri())
plsString.append("\n")
plsString.append("Length$counter=-1")
plsString.append("\n")
counter++
}
// add ending tag
plsString.append("\n")
plsString.append("NumberOfEntries=${collection.stations.size}")
plsString.append("\n")
plsString.append("Version=2")
return plsString.toString()
}
/* Sends a broadcast containing the collection as parcel */
fun sendCollectionBroadcast(context: Context, modificationDate: Date) {
Log.v(TAG, "Broadcasting that collection has changed.")
val collectionChangedIntent = Intent()
collectionChangedIntent.action = Keys.ACTION_COLLECTION_CHANGED
collectionChangedIntent.putExtra(
Keys.EXTRA_COLLECTION_MODIFICATION_DATE,
modificationDate.time
)
LocalBroadcastManager.getInstance(context).sendBroadcast(collectionChangedIntent)
}
// /* Creates MediaMetadata for a single station - used in media session*/
// fun buildStationMediaMetadata(context: Context, station: Station, metadata: String): MediaMetadataCompat {
// return MediaMetadataCompat.Builder().apply {
// putString(MediaMetadataCompat.METADATA_KEY_ARTIST, station.name)
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata)
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM, context.getString(R.string.app_name))
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, station.getStreamUri())
// putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
// //putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, station.image)
// }.build()
// }
//
//
// /* Creates MediaItem for a station - used by collection provider */
// fun buildStationMediaMetaItem(context: Context, station: Station): MediaBrowserCompat.MediaItem {
// val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
// mediaDescriptionBuilder.setMediaId(station.uuid)
// mediaDescriptionBuilder.setTitle(station.name)
// mediaDescriptionBuilder.setIconBitmap(ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
// // mediaDescriptionBuilder.setIconUri(station.image.toUri())
// return MediaBrowserCompat.MediaItem(mediaDescriptionBuilder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
// }
//
//
// /* Creates description for a station - used in MediaSessionConnector */
// fun buildStationMediaDescription(context: Context, station: Station, metadata: String): MediaDescriptionCompat {
// val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN)
// val extras: Bundle = Bundle()
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, coverBitmap)
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, coverBitmap)
// return MediaDescriptionCompat.Builder().apply {
// setMediaId(station.uuid)
// setIconBitmap(coverBitmap)
// setIconUri(station.image.toUri())
// setTitle(metadata)
// setSubtitle(station.name)
// setExtras(extras)
// }.build()
// }
/* Creates a MediaItem with MediaMetadata for a single radio station - used to prepare player */
fun buildMediaItem(context: Context, station: Station): MediaItem {
// todo implement HLS MediaItems
// put uri in RequestMetadata - credit: https://stackoverflow.com/a/70103460
val requestMetadata = MediaItem.RequestMetadata.Builder().apply {
setMediaUri(station.getStreamUri().toUri())
}.build()
// build MediaMetadata
val mediaMetadata = MediaMetadata.Builder().apply {
setArtist(station.name)
//setTitle(station.name)
/* check for "file://" prevents a crash when an old backup was restored */
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
//setArtworkUri(station.image.toUri())
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else {
//setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
setIsBrowsable(false)
setIsPlayable(true)
}.build()
// build MediaItem and return it
return MediaItem.Builder().apply {
setMediaId(station.uuid)
setRequestMetadata(requestMetadata)
setMediaMetadata(mediaMetadata)
//setMimeType(station.getMediaType())
setUri(station.getStreamUri().toUri())
}.build()
}
/* Sorts radio stations */
fun sortCollection(collection: Collection): Collection {
val favoriteStations = collection.stations.filter { it.starred }
val otherStations = collection.stations.filter { !it.starred }
collection.stations = (favoriteStations + otherStations) as MutableList<Station>
return collection
}
/* Get favicon address */
private fun getFaviconAddress(urlString: String): String {
var faviconAddress = String()
try {
var host: String = URL(urlString).host
if (!host.startsWith("www")) {
val index = host.indexOf(".")
host = "www" + host.substring(index)
}
faviconAddress = "http://$host/favicon.ico"
} catch (e: Exception) {
Log.e(TAG, "Unable to get base URL from $urlString.\n$e ")
}
return faviconAddress
}
}