/* * 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 = 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 { val mediaItems: MutableList = 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 { val stationList: MutableList = 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 = NetworkHelper.downloadPlaylist(query) stationList.addAll(readM3uPlaylistContent(lines)) } // CASE: PLS playlist detected else if (Keys.MIME_TYPES_PLS.contains(contentType)) { val lines: List = 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 { val stationList: MutableList = 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): List { val stations: MutableList = 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): List { val stations: MutableList = 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 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 } }