commit 2162c9fb409811d15a9110d0db0055daab584bd1 Author: Michatec <121869403+Michatec@users.noreply.github.com> Date: Sun Apr 27 15:07:05 2025 +0200 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac6f9bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.iml +.gradle +.DS_Store +/local.properties +/.idea +/build +/captures \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4bd6582 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) +===================== + +Copyright (c) 2025 - Michatec +-------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a6720c --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +
+ +### ℹ️ About Radio +**Radio is an application with a minimalist approach to listening to radio over the Internet.**
+**Radio only offers a very basic search option, and it imports audio streaming links when you tap them in a web browser.**
+**Pull request are welcome at any time.**
+ +**Radio is free software. It is released under the [MIT open source license](https://opensource.org/licenses/MIT).** +
+ +---------------------------------------- + +
+⚙️ Install Radio +
+ +
+ +---------------------------------------- + +
+💡 Frequent Questions + +Q: How can I add a radio station +A: There are three ways to add a radio station to Radio: Use Search, add playlist file address (M3U, PLS), enter a raw stream address. The last way will not support the update feature. + +------------------------------------------------------------------------- + +Q: How does the update feature work? +A: The update feature will try to fetch the current stream address of a station as well as the updated name and station image. The feature will not work for stations added via a raw stream address, or for stations imported from Radio v3. + +------------------------------------------------------------------------- + +Q: Where do the radio station search results come from? +A: Radio searches the [radio-browser.info](http://www.radio-browser.info/) online database. +You can help out the radio-browser.info community by [adding the missing station](http://www.radio-browser.info/gui/#!/add) to their database. +
+ +---------------------------------------- + +
+🔊 Supported formats +
+ +| Supported formats | 🔊 | +| ------------------ | -- | +| AAC | ✅ | +| AAC+ | ✅ | +| FLAC | ✅ | +| HLS (M3U8) | ✅ | +| M3U | ✅ | +| MP3 | ✅ | +| OGG (Vorbis) | ✅ | +| OPUS | ✅ | +| PLS | ✅ | +
+ +---------------------------------------- + +
+📜️ Credit + +Base app Michatec. +
+ +
+
+↥ Scroll to top +
+
diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..67aaf57 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +/build +/release +/debug \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..e47ed84 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,68 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' + +android { + namespace 'com.michatec.radio' + compileSdk 34 + + defaultConfig { + applicationId 'com.michatec.radio' + minSdk 23 + targetSdk 34 + versionCode 128 + versionName '12.8' + resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk'] + setProperty('archivesBaseName', 'Radio_' + versionName) + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig true + } + + buildTypes { + debug { + minifyEnabled false + shrinkResources false + crunchPngs false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + applicationIdSuffix = ".debug" + } + + release { + minifyEnabled true + shrinkResources true + crunchPngs true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + // Google Stuff // + implementation 'com.google.android.material:material:1.10.0' + implementation 'com.google.code.gson:gson:2.10.1' + + // AndroidX Stuff // + implementation 'androidx.activity:activity-ktx:1.8.1' + implementation 'androidx.palette:palette-ktx:1.0.0' + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.media:media:1.7.0' + implementation 'androidx.media3:media3-exoplayer:1.2.0' + implementation 'androidx.media3:media3-exoplayer-hls:1.2.0' + implementation 'androidx.media3:media3-session:1.2.0' + implementation 'androidx.media3:media3-datasource-okhttp:1.2.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.5' + implementation 'androidx.work:work-runtime-ktx:2.9.0' + + // Volley HTTP request // + implementation 'com.android.volley:volley:1.2.1' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..cf6209e --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Preserve the core classes - because they need to be de-/serialized with GSON +-keep public class com.michatec.radio.core.** { *; } +-keep public class com.michatec.radio.search.RadioBrowserResult { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d673093 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/michatec/radio/Keys.kt b/app/src/main/java/com/michatec/radio/Keys.kt new file mode 100644 index 0000000..9dbdb7a --- /dev/null +++ b/app/src/main/java/com/michatec/radio/Keys.kt @@ -0,0 +1,170 @@ +/* + * Keys.kt + * Implements the keys used throughout the app + * This object hosts all keys used to control Radio state + * + * 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 + +import java.util.* + + +/* + * Keys object + */ +object Keys { + + // version numbers + const val CURRENT_COLLECTION_CLASS_VERSION_NUMBER: Int = 0 + + // time values + const val SLEEP_TIMER_DURATION = "SLEEP_TIMER_DURATION" + const val RECONNECTION_WAIT_INTERVAL: Long = 5000L // 5 seconds in milliseconds + + // intent actions + const val ACTION_SHOW_PLAYER: String = "com.michatec.radio.action.SHOW_PLAYER" + const val ACTION_COLLECTION_CHANGED: String = "com.michatec.radio.action.COLLECTION_CHANGED" + const val ACTION_START: String = "com.michatec.radio.action.START" + + // intent extras + const val EXTRA_COLLECTION_MODIFICATION_DATE: String = "COLLECTION_MODIFICATION_DATE" + const val EXTRA_STATION_UUID: String = "STATION_UUID" + const val EXTRA_STREAM_URI: String = "STREAM_URI" + const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION" + const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING" + const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY" + + // arguments + const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection" + const val ARG_UPDATE_IMAGES: String = "ArgUpdateImages" + const val ARG_RESTORE_COLLECTION: String = "ArgRestoreCollection" + + // keys + const val KEY_SAVE_INSTANCE_STATE_STATION_LIST: String = "SAVE_INSTANCE_STATE_STATION_LIST" + const val KEY_STREAM_URI: String = "STREAM_URI" + + // custom MediaController commands + const val CMD_START_SLEEP_TIMER: String = "START_SLEEP_TIMER" + const val CMD_CANCEL_SLEEP_TIMER: String = "CANCEL_SLEEP_TIMER" + const val CMD_PLAY_STREAM: String = "PLAY_STREAM" + const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING" + const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY" + + // preferences + const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API" + const val PREF_ONE_TIME_HOUSEKEEPING_NECESSARY: String = "ONE_TIME_HOUSEKEEPING_NECESSARY_VERSIONCODE_95" // increment to current app version code to trigger housekeeping that runs only once + const val PREF_THEME_SELECTION: String = "THEME_SELECTION" + const val PREF_LAST_UPDATE_COLLECTION: String = "LAST_UPDATE_COLLECTION" + const val PREF_COLLECTION_SIZE: String = "COLLECTION_SIZE" + const val PREF_COLLECTION_MODIFICATION_DATE: String = "COLLECTION_MODIFICATION_DATE" + const val PREF_ACTIVE_DOWNLOADS: String = "ACTIVE_DOWNLOADS" + const val PREF_DOWNLOAD_OVER_MOBILE: String = "DOWNLOAD_OVER_MOBILE" + const val PREF_STATION_LIST_EXPANDED_UUID = "STATION_LIST_EXPANDED_UUID" + const val PREF_PLAYER_STATE_STATION_UUID: String = "PLAYER_STATE_STATION_UUID" + const val PREF_PLAYER_STATE_IS_PLAYING: String = "PLAYER_STATE_IS_PLAYING" + const val PREF_PLAYER_METADATA_HISTORY: String = "PLAYER_METADATA_HISTORY" + const val PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING: String = "PLAYER_STATE_SLEEP_TIMER_RUNNING" + const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE" + const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS" + const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS" + + // default const values + const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25 + const val DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY: Int = 127 + const val DEFAULT_DOWNLOAD_OVER_MOBILE: Boolean = false + const val ACTIVE_DOWNLOADS_EMPTY: String = "zero" + const val DEFAULT_MAX_RECONNECTION_COUNT: Int = 30 + const val LARGE_BUFFER_SIZE_MULTIPLIER: Int = 8 + + // view types + const val VIEW_TYPE_ADD_NEW: Int = 1 + const val VIEW_TYPE_STATION: Int = 2 + + // view holder update types + const val HOLDER_UPDATE_COVER: Int = 0 + const val HOLDER_UPDATE_NAME: Int = 1 + const val HOLDER_UPDATE_PLAYBACK_STATE: Int = 2 + const val HOLDER_UPDATE_DOWNLOAD_STATE: Int = 3 + const val HOLDER_UPDATE_PLAYBACK_PROGRESS: Int = 4 + + // dialog types + const val DIALOG_UPDATE_COLLECTION: Int = 1 + const val DIALOG_REMOVE_STATION: Int = 2 + const val DIALOG_UPDATE_STATION_IMAGES: Int = 4 + const val DIALOG_RESTORE_COLLECTION: Int = 5 + + // dialog results + const val DIALOG_EMPTY_PAYLOAD_STRING: String = "" + const val DIALOG_EMPTY_PAYLOAD_INT: Int = -1 + + // search types + const val SEARCH_TYPE_BY_KEYWORD = 0 + const val SEARCH_TYPE_BY_UUID = 1 + + // file types + const val FILE_TYPE_PLAYLIST: Int = 10 + const val FILE_TYPE_AUDIO: Int = 20 + const val FILE_TYPE_IMAGE: Int = 3 + + // mime types and charsets and file extensions + const val CHARSET_UNDEFINDED = "undefined" + const val MIME_TYPE_JPG = "image/jpeg" + const val MIME_TYPE_PNG = "image/png" + const val MIME_TYPE_M3U = "audio/x-mpegurl" + const val MIME_TYPE_PLS = "audio/x-scpls" + const val MIME_TYPE_ZIP = "application/zip" + const val MIME_TYPE_OCTET_STREAM = "application/octet-stream" + const val MIME_TYPE_UNSUPPORTED = "unsupported" + val MIME_TYPES_M3U = arrayOf("application/mpegurl", "application/x-mpegurl", "audio/mpegurl", "audio/x-mpegurl") + val MIME_TYPES_PLS = arrayOf("audio/x-scpls", "application/pls+xml") + val MIME_TYPES_HLS = arrayOf("application/vnd.apple.mpegurl", "application/vnd.apple.mpegurl.audio") + val MIME_TYPES_MPEG = arrayOf("audio/mpeg") + val MIME_TYPES_OGG = arrayOf("audio/ogg", "application/ogg", "audio/opus") + val MIME_TYPES_AAC = arrayOf("audio/aac", "audio/aacp") + val MIME_TYPES_IMAGE = arrayOf("image/png", "image/jpeg") + val MIME_TYPES_FAVICON = arrayOf("image/x-icon", "image/vnd.microsoft.icon") + val MIME_TYPES_ZIP = arrayOf("application/zip", "application/x-zip-compressed", "multipart/x-zip") + + // folder names + const val FOLDER_COLLECTION: String = "collection" + const val FOLDER_AUDIO: String = "audio" + const val FOLDER_IMAGES: String = "images" + const val FOLDER_TEMP: String = "temp" + const val URLRADIO_LEGACY_FOLDER_COLLECTION: String = "Collection" + + // file names and extensions + const val COLLECTION_FILE: String = "collection.json" + const val COLLECTION_M3U_FILE: String = "collection.m3u" + const val COLLECTION_PLS_FILE: String = "collection.pls" + const val STATION_IMAGE_FILE: String = "station-image.jpg" + + // server addresses + const val RADIO_BROWSER_API_BASE: String = "all.api.radio-browser.info" + const val RADIO_BROWSER_API_DEFAULT: String = "de1.api.radio-browser.info" + + // locations + const val LOCATION_DEFAULT_STATION_IMAGE: String = "android.resource://com.michatec.radio/drawable/ic_default_station_image_24dp" + + // sizes (in dp) + const val SIZE_STATION_IMAGE_CARD: Int = 72 + const val SIZE_STATION_IMAGE_MAXIMUM: Int = 640 + const val BOTTOM_SHEET_PEEK_HEIGHT: Int = 72 + + // default values + val DEFAULT_DATE: Date = Date(0L) + const val EMPTY_STRING_RESOURCE: Int = 0 + + // theme states + const val STATE_THEME_FOLLOW_SYSTEM: String = "stateFollowSystem" + const val STATE_THEME_LIGHT_MODE: String = "stateLightMode" + const val STATE_THEME_DARK_MODE: String = "stateDarkMode" + +} diff --git a/app/src/main/java/com/michatec/radio/MainActivity.kt b/app/src/main/java/com/michatec/radio/MainActivity.kt new file mode 100644 index 0000000..d5ce898 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/MainActivity.kt @@ -0,0 +1,108 @@ +/* + * MainActivity.kt + * Implements the MainActivity class + * MainActivity is the default activity that can host the player fragment and the settings fragment + * + * 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 + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.NavigationUI +import androidx.navigation.ui.navigateUp +import com.michatec.radio.helpers.AppThemeHelper +import com.michatec.radio.helpers.FileHelper +import com.michatec.radio.helpers.ImportHelper +import com.michatec.radio.helpers.PreferencesHelper + + +/* + * MainActivity class + */ +class MainActivity : AppCompatActivity() { + + /* Main class variables */ + private lateinit var appBarConfiguration: AppBarConfiguration + + + /* Overrides onCreate from AppCompatActivity */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // house-keeping: determine if edit stations is enabled by default todo: remove in 2023 + if (PreferencesHelper.isHouseKeepingNecessary()) { + // house-keeping 1: remove hard coded default image + ImportHelper.removeDefaultStationImageUris(this) + // house-keeping 2: if existing user detected, enable Edit Stations by default + if (PreferencesHelper.loadCollectionSize() != -1) { + // existing user detected - enable Edit Stations by default + PreferencesHelper.saveEditStationsEnabled(true) + } + PreferencesHelper.saveHouseKeepingNecessaryState() + } + + // set up views + setContentView(R.layout.activity_main) + + // create .nomedia file - if not yet existing + FileHelper.createNomediaFile(getExternalFilesDir(null)) + + // set up action bar + setSupportActionBar(findViewById(R.id.main_toolbar)) + val toolbar: Toolbar = findViewById(R.id.main_toolbar) + val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment + val navController = navHostFragment.navController + appBarConfiguration = AppBarConfiguration(navController.graph) + NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration) + supportActionBar?.hide() + + // register listener for changes in shared preferences + PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener) + } + + + /* Overrides onSupportNavigateUp from AppCompatActivity */ + override fun onSupportNavigateUp(): Boolean { + // Taken from: https://developer.android.com/guide/navigation/navigation-ui#action_bar + val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment + val navController = navHostFragment.navController + return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() + } + + + /* Overrides onDestroy from AppCompatActivity */ + override fun onDestroy() { + super.onDestroy() + // unregister listener for changes in shared preferences + PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener) + } + + + /* + * Defines the listener for changes in shared preferences + */ + private val sharedPreferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + Keys.PREF_THEME_SELECTION -> { + AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection()) + } + } + } + /* + * End of declaration + */ + +} diff --git a/app/src/main/java/com/michatec/radio/PlayerFragment.kt b/app/src/main/java/com/michatec/radio/PlayerFragment.kt new file mode 100644 index 0000000..788f746 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/PlayerFragment.kt @@ -0,0 +1,828 @@ +/* + * PlayerFragment.kt + * Implements the PlayerFragment class + * PlayerFragment is the fragment that hosts Radio's list of stations and a player sheet + * + * 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 + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.media.AudioManager +import android.net.Uri +import android.os.* +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionResult +import androidx.media3.session.SessionToken +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_KEYBOARD +import com.google.android.material.timepicker.TimeFormat +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.michatec.radio.collection.CollectionAdapter +import com.michatec.radio.collection.CollectionViewModel +import com.michatec.radio.core.Collection +import com.michatec.radio.core.Station +import com.michatec.radio.dialogs.AddStationDialog +import com.michatec.radio.dialogs.FindStationDialog +import com.michatec.radio.dialogs.YesNoDialog +import com.michatec.radio.extensions.* +import com.michatec.radio.helpers.* +import com.michatec.radio.ui.LayoutHolder +import com.michatec.radio.ui.PlayerState +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import java.util.* + + +/* + * PlayerFragment class + */ +class PlayerFragment : Fragment(), + SharedPreferences.OnSharedPreferenceChangeListener, + FindStationDialog.FindStationDialogListener, + AddStationDialog.AddStationDialogListener, + CollectionAdapter.CollectionAdapterListener, + YesNoDialog.YesNoDialogListener { + + /* Define log tag */ + private val TAG: String = PlayerFragment::class.java.simpleName + + /* Main class variables */ + private lateinit var collectionViewModel: CollectionViewModel + private lateinit var layout: LayoutHolder + private lateinit var collectionAdapter: CollectionAdapter + private lateinit var controllerFuture: ListenableFuture + private lateinit var pickSingleMediaLauncher: ActivityResultLauncher + private lateinit var queue: RequestQueue + private val controller: MediaController? + get() = if (controllerFuture.isDone) controllerFuture.get() else null // defines the Getter for the MediaController + private var collection: Collection = Collection() + private var playerState: PlayerState = PlayerState() + private var listLayoutState: Parcelable? = null + private val handler: Handler = Handler(Looper.getMainLooper()) + private var tempStationUuid: String = String() + private var itemTouchHelper: ItemTouchHelper? = null + + + /* Overrides onCreate from Fragment */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // handle back tap/gesture + requireActivity().onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // minimize player sheet - or if already minimized let activity handle back + if (isEnabled && this@PlayerFragment::layout.isInitialized && !layout.minimizePlayerIfExpanded()) { + isEnabled = false + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + }) + + queue = Volley.newRequestQueue(requireActivity()) + + // load player state + playerState = PreferencesHelper.loadPlayerState() + + // create view model and observe changes in collection view model + collectionViewModel = ViewModelProvider(this)[CollectionViewModel::class.java] + + // create collection adapter + collectionAdapter = CollectionAdapter( + activity as Context, + this as CollectionAdapter.CollectionAdapterListener + ) + + // restore state of station list + listLayoutState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + savedInstanceState?.getParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST, Parcelable::class.java) + } else { + @Suppress("DEPRECATION") + savedInstanceState?.getParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST) + } + + // Initialize single media picker launcher + pickSingleMediaLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { imageUri -> + if (imageUri == null) { + Snackbar.make(requireView(), R.string.toastalert_failed_picking_media, Snackbar.LENGTH_LONG).show() + } else { + collection = CollectionHelper.setStationImageWithStationUuid( + activity as Context, + collection, + imageUri, + tempStationUuid, + imageManuallySet = true + ) + tempStationUuid = String() + } + } + + Handler(Looper.getMainLooper()).postDelayed({ context?.let { checkForUpdates() } }, 5000) + } + + + /* Overrides onCreate from Fragment */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + // find views and set them up + val rootView: View = inflater.inflate(R.layout.fragment_player, container, false) + layout = LayoutHolder(rootView) + + initializeViews() + + // hide action bar + (activity as AppCompatActivity).supportActionBar?.hide() + + // set the same background color of the player sheet for the navigation bar + (activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireActivity(), R.color.player_sheet_background) + + // associate the ItemTouchHelper with the RecyclerView + itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback()) + itemTouchHelper?.attachToRecyclerView(layout.recyclerView) + + return rootView + } + + + /* Implement the ItemTouchHelper.Callback for drag and drop functionality */ + inner class ItemTouchHelperCallback : ItemTouchHelper.Callback() { + + override fun isLongPressDragEnabled() = !collectionAdapter.isExpandedForEdit + + override fun isItemViewSwipeEnabled() = true + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + // disable drag and drop for the new card + if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) { + return 0 + } + + val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN + val swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val fromPosition = viewHolder.adapterPosition + val toPosition = target.adapterPosition + collectionAdapter.onItemMove(fromPosition, toPosition) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val position = viewHolder.adapterPosition + collectionAdapter.onItemDismiss(position) + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + collectionAdapter.saveCollectionAfterDragDrop() + } + } + + + /* Overrides onStart from Fragment */ + override fun onStart() { + super.onStart() + // initialize MediaController - connect to PlayerService + initializeController() + } + + + /* Overrides onSaveInstanceState from Fragment */ + override fun onSaveInstanceState(outState: Bundle) { + if (this::layout.isInitialized) { + // save current state of station list + listLayoutState = layout.layoutManager.onSaveInstanceState() + outState.putParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST, listLayoutState) + } + // always call the superclass so it can save the view hierarchy state + super.onSaveInstanceState(outState) + } + + + /* Overrides onResume from Fragment */ + override fun onResume() { + super.onResume() + // assign volume buttons to music volume + activity?.volumeControlStream = AudioManager.STREAM_MUSIC + // load player state + playerState = PreferencesHelper.loadPlayerState() + // recreate player ui +// setupPlaybackControls() + updatePlayerViews() + updateStationListState() + togglePeriodicSleepTimerUpdateRequest() + // begin looking for changes in collection + observeCollectionViewModel() + // handle navigation arguments + handleNavigationArguments() +// // handle start intent - if started via tap on rss link +// handleStartIntent() + // start watching for changes in shared preferences + PreferencesHelper.registerPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener) + } + + + /* Overrides onPause from Fragment */ + override fun onPause() { + super.onPause() + // stop receiving playback progress updates + handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable) + // stop watching for changes in shared preferences + PreferencesHelper.unregisterPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener) + + } + + + /* Overrides onStop from Fragment */ + override fun onStop() { + super.onStop() + // release MediaController - cut connection to PlayerService + releaseController() + } + + override fun onDestroy() { + super.onDestroy() + queue.cancelAll(TAG) + } + + /* Overrides onSharedPreferenceChanged from OnSharedPreferenceChangeListener */ + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == Keys.PREF_ACTIVE_DOWNLOADS) { + layout.toggleDownloadProgressIndicator() + } + if (key == Keys.PREF_PLAYER_METADATA_HISTORY) { + requestMetadataUpdate() + } + } + + + /* Overrides onFindStationDialog from FindStationDialog */ + override fun onFindStationDialog(station: Station) { + if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) { + // add station and save collection + collection = CollectionHelper.addStation(activity as Context, collection, station) + } else { + // detect content type on background thread + CoroutineScope(IO).launch { + val contentType: NetworkHelper.ContentType = NetworkHelper.detectContentType(station.getStreamUri()) + // set content type + station.streamContent = contentType.type + // add station and save collection + withContext(Main) { + collection = CollectionHelper.addStation(activity as Context, collection, station) + } + } + } + } + + + /* Overrides onAddStationDialog from AddDialog */ + override fun onAddStationDialog(station: Station) { + if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) { + // add station and save collection + collection = CollectionHelper.addStation(activity as Context, collection, station) + } + } + + + /* Overrides onPlayButtonTapped from CollectionAdapterListener */ + override fun onPlayButtonTapped(stationUuid: String) { + // CASE: the selected station is playing + if (controller?.isPlaying == true && stationUuid == playerState.stationUuid) { + // stop playback + controller?.pause() + } + // CASE: the selected station is not playing (another station might be playing) + else { + // start playback + controller?.play(activity as Context, CollectionHelper.getStation(collection, stationUuid)) + } + } + + + /* Overrides onAddNewButtonTapped from CollectionAdapterListener */ + override fun onAddNewButtonTapped() { + FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show() + } + + + /* Overrides onChangeImageButtonTapped from CollectionAdapterListener */ + override fun onChangeImageButtonTapped(stationUuid: String) { + tempStationUuid = stationUuid + pickImage() + } + + + /* Overrides onYesNoDialog from YesNoDialogListener */ + override fun onYesNoDialog( + type: Int, + dialogResult: Boolean, + payload: Int, + payloadString: String + ) { + super.onYesNoDialog(type, dialogResult, payload, payloadString) + when (type) { + // handle result of remove dialog + Keys.DIALOG_REMOVE_STATION -> { + when (dialogResult) { + // user tapped remove station + true -> collectionAdapter.removeStation(activity as Context, payload) + // user tapped cancel + false -> collectionAdapter.notifyItemChanged(payload) + } + } + // handle result from the restore collection dialog + Keys.DIALOG_RESTORE_COLLECTION -> { + when (dialogResult) { + // user tapped restore + true -> BackupHelper.restore(requireView(), activity as Context, payloadString.toUri()) + // user tapped cancel + false -> { + /* do nothing */ + } + } + } + } + } + + + /* Initializes the MediaController - handles connection to PlayerService under the hood */ + private fun initializeController() { + controllerFuture = MediaController.Builder( + activity as Context, + SessionToken( + activity as Context, + ComponentName(activity as Context, PlayerService::class.java) + ) + ).buildAsync() + controllerFuture.addListener({ setupController() }, MoreExecutors.directExecutor()) + } + + + /* Releases MediaController */ + private fun releaseController() { + MediaController.releaseFuture(controllerFuture) + } + + + /* Sets up the MediaController */ + private fun setupController() { + val controller: MediaController = this.controller ?: return + controller.addListener(playerListener) + requestMetadataUpdate() + // handle start intent + handleStartIntent() + } + + + /* Sets up views and connects tap listeners - first run */ + private fun initializeViews() { + // set adapter data source + layout.recyclerView.adapter = collectionAdapter + + // enable swipe to delete + val swipeToDeleteHandler = object : UiHelper.SwipeToDeleteCallback(activity as Context) { + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // ask user + val adapterPosition: Int = viewHolder.adapterPosition + val dialogMessage = + "${getString(R.string.dialog_yes_no_message_remove_station)}\n\n- ${collection.stations[adapterPosition].name}" + YesNoDialog(this@PlayerFragment as YesNoDialog.YesNoDialogListener).show( + context = activity as Context, + type = Keys.DIALOG_REMOVE_STATION, + messageString = dialogMessage, + yesButton = R.string.dialog_yes_no_positive_button_remove_station, + payload = adapterPosition + ) + } + } + val swipeToDeleteItemTouchHelper = ItemTouchHelper(swipeToDeleteHandler) + swipeToDeleteItemTouchHelper.attachToRecyclerView(layout.recyclerView) + + // enable swipe to mark starred + val swipeToMarkStarredHandler = + object : UiHelper.SwipeToMarkStarredCallback(activity as Context) { + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // mark card starred + val adapterPosition: Int = viewHolder.adapterPosition + collectionAdapter.toggleStarredStation(activity as Context, adapterPosition) + } + } + val swipeToMarkStarredItemTouchHelper = ItemTouchHelper(swipeToMarkStarredHandler) + swipeToMarkStarredItemTouchHelper.attachToRecyclerView(layout.recyclerView) + + // set up sleep timer start button + layout.sheetSleepTimerStartButtonView.setOnClickListener { + when (controller?.isPlaying) { + true -> { + val timePicker = MaterialTimePicker.Builder() + .setTimeFormat(TimeFormat.CLOCK_24H) + .setHour(0) + .setMinute(1) + .setInputMode(INPUT_MODE_KEYBOARD) + .build() + + timePicker.addOnPositiveButtonClickListener { + val selectedTimeMillis = (timePicker.hour * 60 * 60 * 1000L) + (timePicker.minute * 60 * 1000L) + 1000 + // start the sleep timer with the selected time + playerState.sleepTimerRunning = true + controller?.startSleepTimer(selectedTimeMillis) + togglePeriodicSleepTimerUpdateRequest() + } + + // display the TimePicker dialog + timePicker.show(requireActivity().supportFragmentManager, "tag") + } + else -> Snackbar.make( + requireView(), + R.string.toastmessage_sleep_timer_unable_to_start, + Snackbar.LENGTH_SHORT + ).show() + } + } + + // set up sleep timer cancel button + layout.sheetSleepTimerCancelButtonView.setOnClickListener { + playerState.sleepTimerRunning = false + controller?.cancelSleepTimer() + togglePeriodicSleepTimerUpdateRequest() + } + + } + + +// /* Sets up the general playback controls - Note: station specific controls and views are updated in updatePlayerViews() */ +// // it is probably okay to suppress this warning - the OnTouchListener on the time played view does only toggle the time duration / remaining display +// private fun setupPlaybackControls() { +// +// // main play/pause button +// layout.playButtonView.setOnClickListener { +// onPlayButtonTapped(playerState.stationUuid, playerState.playbackState) +// //onPlayButtonTapped(playerState.stationUuid, playerController.getPlaybackState().state) // todo remove +// } +// +// // register a callback to stay in sync +// playerController.registerCallback(mediaControllerCallback) +// } + + + /* Sets up the player */ + private fun updatePlayerViews() { + // get station + var station = Station() + if (playerState.stationUuid.isNotEmpty()) { + // get station from player state + station = CollectionHelper.getStation(collection, playerState.stationUuid) + } else if (collection.stations.isNotEmpty()) { + // fallback: get first station + station = collection.stations[0] + playerState.stationUuid = station.uuid + } + // update views + layout.togglePlayButton(playerState.isPlaying) + layout.updatePlayerViews(activity as Context, station, playerState.isPlaying) + + // main play/pause button + layout.playButtonView.setOnClickListener { + onPlayButtonTapped(playerState.stationUuid) + } + } + + + /* Sets up state of list station list */ + private fun updateStationListState() { + if (listLayoutState != null) { + layout.layoutManager.onRestoreInstanceState(listLayoutState) + } + } + + + /* Requests an update of the sleep timer from the player service */ + private fun requestSleepTimerUpdate() { + val resultFuture: ListenableFuture? = + controller?.requestSleepTimerRemaining() + resultFuture?.addListener(Runnable { + val timeRemaining: Long = resultFuture.get().extras.getLong(Keys.EXTRA_SLEEP_TIMER_REMAINING) + layout.updateSleepTimer(activity as Context, timeRemaining) + }, MoreExecutors.directExecutor()) + } + + + /* Requests an update of the metadata history from the player service */ + private fun requestMetadataUpdate() { + val resultFuture: ListenableFuture? = controller?.requestMetadataHistory() + resultFuture?.addListener(Runnable { + val metadata: ArrayList? = resultFuture.get().extras.getStringArrayList(Keys.EXTRA_METADATA_HISTORY) + layout.updateMetadata(metadata?.toMutableList()) + }, MoreExecutors.directExecutor()) + } + + + /* Start image picker */ + private fun pickImage() { + pickSingleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + + /* Handles this activity's start intent */ + private fun handleStartIntent() { + if ((activity as Activity).intent.action != null) { + when ((activity as Activity).intent.action) { + Keys.ACTION_SHOW_PLAYER -> handleShowPlayer() + Intent.ACTION_VIEW -> handleViewIntent() + Keys.ACTION_START -> handleStartPlayer() + } + } + // clear intent action to prevent double calls + (activity as Activity).intent.action = "" + } + + + /* Handles ACTION_SHOW_PLAYER request from notification */ + private fun handleShowPlayer() { + Log.i(TAG, "Tap on notification registered.") + // todo implement + } + + + /* Handles ACTION_VIEW request to add Station */ + private fun handleViewIntent() { + val intentUri: Uri? = (activity as Activity).intent.data + if (intentUri != null) { + CoroutineScope(IO).launch { + // get station list from intent source + val stationList: MutableList = mutableListOf() + val scheme: String = intentUri.scheme ?: String() + // CASE: intent is a web link + if (scheme.startsWith("http")) { + Log.i(TAG, "Transistor was started to handle a web link.") + stationList.addAll(CollectionHelper.createStationsFromUrl(intentUri.toString())) + } + // CASE: intent is a local file + else if (scheme.startsWith("content")) { + Log.i(TAG, "Transistor was started to handle a local audio playlist.") + stationList.addAll(CollectionHelper.createStationListFromContentUri(activity as Context, intentUri)) + } + withContext(Main) { + if (stationList.isNotEmpty()) { + AddStationDialog(activity as Activity, stationList, this@PlayerFragment as AddStationDialog.AddStationDialogListener).show() + } else { + // invalid address + Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show() + } + } + } + } + } + + + /* Handles START_PLAYER_SERVICE request from App Shortcut */ + private fun handleStartPlayer() { + val intent: Intent = (activity as Activity).intent + if (intent.hasExtra(Keys.EXTRA_START_LAST_PLAYED_STATION)) { + controller?.play(activity as Context, CollectionHelper.getStation(collection, playerState.stationUuid)) + } else if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) { + val uuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String() + controller?.play(activity as Context, CollectionHelper.getStation(collection, uuid)) + } else if (intent.hasExtra(Keys.EXTRA_STREAM_URI)) { + val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String() + controller?.playStreamDirectly(streamUri) + } + } + + + /* Toggle periodic update request of Sleep Timer state from player service */ + private fun togglePeriodicSleepTimerUpdateRequest() { + handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable) + handler.postDelayed(periodicSleepTimerUpdateRequestRunnable, 0) + } + + + /* Observe view model of collection of stations */ + private fun observeCollectionViewModel() { + collectionViewModel.collectionLiveData.observe(this) { + // update collection + collection = it +//// // updates current station in player views +//// playerState = PreferencesHelper.loadPlayerState() +// // get station +// val station: Station = CollectionHelper.getStation(collection, playerState.stationUuid) +// // update player views +// layout.updatePlayerViews(activity as Context, station, playerState.isPlaying) +//// // handle start intent +//// handleStartIntent() +//// // handle navigation arguments +//// handleNavigationArguments() + } + collectionViewModel.collectionSizeLiveData.observe(this) { + // size of collection changed + layout.toggleOnboarding(activity as Context, collection.stations.size) + updatePlayerViews() + CollectionHelper.exportCollectionM3u(activity as Context, collection) + CollectionHelper.exportCollectionPls(activity as Context, collection) + } + } + + + /* Handles arguments handed over by navigation (from SettingsFragment) */ + private fun handleNavigationArguments() { + // get arguments + val updateCollection: Boolean = + arguments?.getBoolean(Keys.ARG_UPDATE_COLLECTION, false) ?: false + val updateStationImages: Boolean = + arguments?.getBoolean(Keys.ARG_UPDATE_IMAGES, false) ?: false + val restoreCollectionFileString: String? = arguments?.getString(Keys.ARG_RESTORE_COLLECTION) + + if (updateCollection) { + arguments?.putBoolean(Keys.ARG_UPDATE_COLLECTION, false) + val updateHelper = UpdateHelper(activity as Context, collectionAdapter, collection) + updateHelper.updateCollection() + } + if (updateStationImages) { + arguments?.putBoolean(Keys.ARG_UPDATE_IMAGES, false) + DownloadHelper.updateStationImages(activity as Context) + } + if (!restoreCollectionFileString.isNullOrEmpty()) { + arguments?.putString(Keys.ARG_RESTORE_COLLECTION, null) + when (collection.stations.isNotEmpty()) { + true -> { + YesNoDialog(this as YesNoDialog.YesNoDialogListener).show( + context = activity as Context, + type = Keys.DIALOG_RESTORE_COLLECTION, + messageString = getString(R.string.dialog_restore_collection_replace_existing), + payloadString = restoreCollectionFileString + ) + } + false -> { + BackupHelper.restore( + requireView(), + activity as Context, + restoreCollectionFileString.toUri() + ) + } + } + } + } + + + /* + * Runnable: Periodically requests sleep timer state + */ + private val periodicSleepTimerUpdateRequestRunnable: Runnable = object : Runnable { + override fun run() { + // update sleep timer view + requestSleepTimerUpdate() + // use the handler to start runnable again after specified delay + handler.postDelayed(this, 500) + } + } + /* + * End of declaration + */ + + + /* + * Player.Listener: Called when one or more player states changed. + */ + private var playerListener: Player.Listener = object : Player.Listener { + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + // store new station + playerState.stationUuid = mediaItem?.mediaId ?: String() + // update station specific views + updatePlayerViews() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + // store state of playback + playerState.isPlaying = isPlaying + // animate state transition of play button(s) + layout.animatePlaybackButtonStateTransition(activity as Context, isPlaying) + + if (isPlaying) { + // playback is active + layout.showPlayer(activity as Context) + layout.showBufferingIndicator(buffering = false) + } else { + // playback is paused or stopped + // check if buffering (playback is not active but playWhenReady is true) + if (controller?.playWhenReady == true) { + // playback is buffering, show the buffering indicator + layout.showBufferingIndicator(buffering = true) + } else { + // playback is not buffering, hide the buffering indicator + layout.showBufferingIndicator(buffering = false) + } + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + + if (playWhenReady && controller?.isPlaying == false) { + layout.showBufferingIndicator(buffering = true) + } + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + layout.togglePlayButton(false) + layout.showBufferingIndicator(false) + Toast.makeText(activity, R.string.toastmessage_connection_failed, Toast.LENGTH_LONG).show() + } + } + + + /* + * Check for update on github + */ + private fun checkForUpdates() { + val url = getString(R.string.snackbar_github_update_check_url) + val request = StringRequest(Request.Method.GET, url, { reply -> + val latestVersion = Gson().fromJson(reply, JsonObject::class.java).get("tag_name").asString + val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.packageManager?.getPackageInfo(requireActivity().packageName, PackageManager.PackageInfoFlags.of(0))?.versionName + } else { + activity?.packageManager?.getPackageInfo(requireActivity().packageName, 0)?.versionName + } + if (latestVersion != current) { + // We have an update available, tell our user about it + view?.let { + Snackbar.make(it, getString(R.string.app_name) + " " + latestVersion + " " + getString(R.string.snackbar_update_available), 10000) + .setAction(R.string.snackbar_show) { + val releaseurl = getString(R.string.snackbar_url_app_home_page) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(releaseurl) + // Not sure that does anything + i.putExtra("SOURCE", "SELF") + startActivity(i) + } + .setActionTextColor( + ContextCompat.getColor( + requireActivity(), + R.color.default_neutral_white)) + .show() + } + } + }, { error -> + Log.w(TAG, "Update check failed", error) + }) + + request.tag = TAG + queue.add(request) + } + + /* + * End of declaration + */ +} + diff --git a/app/src/main/java/com/michatec/radio/PlayerService.kt b/app/src/main/java/com/michatec/radio/PlayerService.kt new file mode 100644 index 0000000..e7122d2 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/PlayerService.kt @@ -0,0 +1,673 @@ +/* + * PlayerService.kt + * Implements the PlayerService class + * PlayerService is Radio's foreground service that plays radio station audio + * + * 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 + +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.* +import android.media.audiofx.AudioEffect +import android.os.Build +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT +import androidx.media3.common.* +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.HttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.upstream.DefaultAllocator +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.media3.session.* +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.michatec.radio.core.Collection +import com.michatec.radio.helpers.AudioHelper +import com.michatec.radio.helpers.CollectionHelper +import com.michatec.radio.helpers.FileHelper +import com.michatec.radio.helpers.PreferencesHelper +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Main +import java.util.* + + +/* + * PlayerService class + */ +@UnstableApi +class PlayerService : MediaLibraryService() { + + /* Define log tag */ + private val TAG: String = PlayerService::class.java.simpleName + + /* Main class variables */ + private lateinit var player: Player + private lateinit var mediaLibrarySession: MediaLibrarySession + private lateinit var sleepTimer: CountDownTimer + var sleepTimerTimeRemaining: Long = 0L + private var sleepTimerEndTime: Long = 0L + private val librarySessionCallback = CustomMediaLibrarySessionCallback() + private var collection: Collection = Collection() + private lateinit var metadataHistory: MutableList + private var bufferSizeMultiplier: Int = PreferencesHelper.loadBufferSizeMultiplier() + private var playbackRestartCounter: Int = 0 + private var playLastStation: Boolean = false + private var manuallyCancelledSleepTimer = false + + + /* Overrides onCreate from Service */ + override fun onCreate() { + super.onCreate() + // load collection + collection = FileHelper.readCollection(this) + // create and register collection changed receiver + LocalBroadcastManager.getInstance(application).registerReceiver( + collectionChangedReceiver, + IntentFilter(Keys.ACTION_COLLECTION_CHANGED) + ) + // initialize player and session + initializePlayer() + initializeSession() + val notificationProvider: DefaultMediaNotificationProvider = CustomNotificationProvider() + notificationProvider.setSmallIcon(R.drawable.ic_notification_app_icon_white_24dp) + setMediaNotificationProvider(notificationProvider) + // fetch the metadata history + metadataHistory = PreferencesHelper.loadMetadataHistory() + } + + + /* Overrides onDestroy from Service */ + override fun onDestroy() { + // player.removeAnalyticsListener(analyticsListener) + player.removeListener(playerListener) + player.release() + mediaLibrarySession.release() + super.onDestroy() + } + + + /* Overrides onTaskRemoved from Service */ + override fun onTaskRemoved(rootIntent: Intent) { + if (!player.playWhenReady) { + stopSelf() + } + } + + + /* Overrides onGetSession from MediaSessionService */ + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession { + return mediaLibrarySession + } + + + /* Initializes the ExoPlayer */ + private fun initializePlayer() { + val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply { + setAudioAttributes(AudioAttributes.DEFAULT, true) + setHandleAudioBecomingNoisy(true) + setLoadControl(createDefaultLoadControl(bufferSizeMultiplier)) + setMediaSourceFactory( + DefaultMediaSourceFactory(this@PlayerService).setLoadErrorHandlingPolicy( + loadErrorHandlingPolicy + ) + ) + }.build() + exoPlayer.addAnalyticsListener(analyticsListener) + exoPlayer.addListener(playerListener) + + // manually add seek to next and seek to previous since headphones issue them and they are translated to next and previous station + player = object : ForwardingPlayer(exoPlayer) { + override fun getAvailableCommands(): Player.Commands { + return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT) + .add(COMMAND_SEEK_TO_PREVIOUS).build() + } + + override fun isCommandAvailable(command: Int): Boolean { + return availableCommands.contains(command) + } + + override fun getDuration(): Long { + return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification + } + } + } + + + /* Initializes the MediaSession */ + private fun initializeSession() { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = TaskStackBuilder.create(this).run { + addNextIntent(intent) + getPendingIntent(0, if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_CANCEL_CURRENT) + } + + mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback).apply { + setSessionActivity(pendingIntent) + }.build() + } + + + /* Creates a LoadControl - increase buffer size by given factor */ + private fun createDefaultLoadControl(factor: Int): DefaultLoadControl { + val builder = DefaultLoadControl.Builder() + builder.setAllocator(DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)) + builder.setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * factor, + DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * factor, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS * factor, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS * factor + ) + return builder.build() + } + + + /* Starts sleep timer / adds default duration to running sleeptimer */ + private fun startSleepTimer(selectedTimeMillis: Long) { + // stop running timer + if (sleepTimerTimeRemaining > 0L && this::sleepTimer.isInitialized) { + sleepTimer.cancel() + } + + // set the end time of the sleep timer + sleepTimerEndTime = System.currentTimeMillis() + selectedTimeMillis + + // initialize timer + sleepTimer = object : CountDownTimer(selectedTimeMillis, 1000) { + override fun onFinish() { + Log.v(TAG, "Sleep timer finished. Sweet dreams.") + sleepTimerTimeRemaining = 0L + player.stop() + } + + override fun onTick(millisUntilFinished: Long) { + sleepTimerTimeRemaining = millisUntilFinished + } + } + // start timer + sleepTimer.start() + // store timer state + PreferencesHelper.saveSleepTimerRunning(isRunning = true) + } + + + /* Cancels sleep timer */ + private fun cancelSleepTimer() { + if (this::sleepTimer.isInitialized) { + if (manuallyCancelledSleepTimer) { + sleepTimerTimeRemaining = 0L + sleepTimer.cancel() + } + manuallyCancelledSleepTimer = false + } + // store timer state + PreferencesHelper.saveSleepTimerRunning(isRunning = false) + } + + + /* Function to cancel the timer manually */ + fun manuallyCancelSleepTimer() { + manuallyCancelledSleepTimer = true + cancelSleepTimer() + } + + + /* Updates metadata */ + private fun updateMetadata(metadata: String = String()) { + // get metadata string + val metadataString: String = metadata.ifEmpty { + player.currentMediaItem?.mediaMetadata?.artist.toString() + } + // remove duplicates + if (metadataHistory.contains(metadataString)) { + metadataHistory.removeAll { it == metadataString } + } + // append metadata to metadata history + metadataHistory.add(metadataString) + // trim metadata list + if (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) { + metadataHistory.removeAt(0) + } + // save history + PreferencesHelper.saveMetadataHistory(metadataHistory) + } + + + /* Reads collection of stations from storage using GSON */ + private fun loadCollection(context: Context) { + Log.v(TAG, "Loading collection of stations from storage") + CoroutineScope(Main).launch { + // load collection on background thread + val deferred: Deferred = + async(Dispatchers.Default) { FileHelper.readCollectionSuspended(context) } + // wait for result and update collection + collection = deferred.await() +// // special case: trigger metadata view update for stations that have no metadata +// if (player.isPlaying && station.name == getCurrentMetadata()) { +// station = CollectionHelper.getStation(collection, station.uuid) +// updateMetadata(null) +// } + } + } + + + /* + * Custom MediaSession Callback that handles player commands + */ + private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList + ): ListenableFuture> { + val updatedMediaItems: List = + mediaItems.map { mediaItem -> + CollectionHelper.getItem(this@PlayerService, collection, mediaItem.mediaId) +// if (mediaItem.requestMetadata.searchQuery != null) +// getMediaItemFromSearchQuery(mediaItem.requestMetadata.searchQuery!!) +// else MediaItemTree.getItem(mediaItem.mediaId) ?: mediaItem + } + return Futures.immediateFuture(updatedMediaItems) + + +// val updatedMediaItems = mediaItems.map { mediaItem -> +// mediaItem.buildUpon().apply { +// setUri(mediaItem.requestMetadata.mediaUri) +// }.build() +// } +// return Futures.immediateFuture(updatedMediaItems) + } + + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + // add custom commands + val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller) + val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon() + builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY)) + builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY)) + builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY)) + builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY)) + return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands) + } + + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: LibraryParams? + ): ListenableFuture> { + val children: List = CollectionHelper.getChildren(this@PlayerService, collection) + session.notifyChildrenChanged(browser, parentId, children.size, params) + return Futures.immediateFuture(LibraryResult.ofVoid()) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val children: List = CollectionHelper.getChildren(this@PlayerService, collection) + return Futures.immediateFuture(LibraryResult.ofItemList(children, params)) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + return if (params?.extras?.containsKey(EXTRA_RECENT) == true) { + // special case: system requested media resumption via EXTRA_RECENT + playLastStation = true + Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRecent(this@PlayerService, collection), params)) + } else { + Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRootItem(), params)) + } + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val item: MediaItem = CollectionHelper.getItem(this@PlayerService, collection, mediaId) + return Futures.immediateFuture(LibraryResult.ofItem(item, /* params = */ null)) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + when (customCommand.customAction) { + Keys.CMD_START_SLEEP_TIMER -> { + val selectedTimeMillis = args.getLong(Keys.SLEEP_TIMER_DURATION) + startSleepTimer(selectedTimeMillis) + } + Keys.CMD_CANCEL_SLEEP_TIMER -> { + manuallyCancelSleepTimer() + } + Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING -> { + val resultBundle = Bundle() + resultBundle.putLong(Keys.EXTRA_SLEEP_TIMER_REMAINING, sleepTimerTimeRemaining) + return Futures.immediateFuture( + SessionResult( + SessionResult.RESULT_SUCCESS, + resultBundle + ) + ) + } + Keys.CMD_REQUEST_METADATA_HISTORY -> { + val resultBundle = Bundle() + resultBundle.putStringArrayList( + Keys.EXTRA_METADATA_HISTORY, + ArrayList(metadataHistory) + ) + return Futures.immediateFuture( + SessionResult( + SessionResult.RESULT_SUCCESS, + resultBundle + ) + ) + } + } + return super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onPlayerCommandRequest( + session: MediaSession, + controller: MediaSession.ControllerInfo, + playerCommand: Int + ): Int { + // playerCommand = one of COMMAND_PLAY_PAUSE, COMMAND_PREPARE, COMMAND_STOP, COMMAND_SEEK_TO_DEFAULT_POSITION, COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD, COMMAND_SET_SPEED_AND_PITCH, COMMAND_SET_SHUFFLE_MODE, COMMAND_SET_REPEAT_MODE, COMMAND_GET_CURRENT_MEDIA_ITEM, COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_GET_AUDIO_ATTRIBUTES, COMMAND_GET_VOLUME, COMMAND_GET_DEVICE_VOLUME, COMMAND_SET_VOLUME, COMMAND_SET_DEVICE_VOLUME, COMMAND_ADJUST_DEVICE_VOLUME, COMMAND_SET_VIDEO_SURFACE, COMMAND_GET_TEXT, COMMAND_SET_TRACK_SELECTION_PARAMETERS or COMMAND_GET_TRACK_INFOS. */ + // emulate headphone buttons + // start/pause: adb shell input keyevent 85 + // next: adb shell input keyevent 87 + // prev: adb shell input keyevent 88 + when (playerCommand) { + Player.COMMAND_SEEK_TO_NEXT -> { + player.addMediaItem( + CollectionHelper.getNextMediaItem( + this@PlayerService, + collection, + player.currentMediaItem?.mediaId ?: String() + ) + ) + player.prepare() + player.play() + return SessionResult.RESULT_SUCCESS + } + Player.COMMAND_SEEK_TO_PREVIOUS -> { + player.addMediaItem( + CollectionHelper.getPreviousMediaItem( + this@PlayerService, + collection, + player.currentMediaItem?.mediaId ?: String() + ) + ) + player.prepare() + player.play() + return SessionResult.RESULT_SUCCESS + } + Player.COMMAND_PREPARE -> { + return if (playLastStation) { + // special case: system requested media resumption (see also onGetLibraryRoot) + player.addMediaItem(CollectionHelper.getRecent(this@PlayerService, collection)) + player.prepare() + playLastStation = false + SessionResult.RESULT_SUCCESS + } else { + super.onPlayerCommandRequest(session, controller, playerCommand) + } + } + Player.COMMAND_PLAY_PAUSE -> { + return if (player.isPlaying) { + super.onPlayerCommandRequest(session, controller, playerCommand) + } else { + // seek to the start of the "live window" + player.seekTo(0) + SessionResult.RESULT_SUCCESS + } + } +// Player.COMMAND_PLAY_PAUSE -> { +// // override pause with stop, to prevent unnecessary buffering +// if (player.isPlaying) { +// player.stop() +// return SessionResult.RESULT_INFO_SKIPPED +// } else { +// return super.onPlayerCommandRequest(session, controller, playerCommand) +// } +// } + else -> { + return super.onPlayerCommandRequest(session, controller, playerCommand) + } + } + } + } + + + /* + * NotificationProvider to customize Notification actions + */ + private inner class CustomNotificationProvider : + DefaultMediaNotificationProvider(this@PlayerService) { + override fun getMediaButtons( + session: MediaSession, + playerCommands: Player.Commands, + customLayout: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val seekToPreviousCommandButton = CommandButton.Builder().apply { + setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + setIconResId(R.drawable.ic_notification_skip_to_previous_36dp) + setEnabled(true) + }.build() + val playCommandButton = CommandButton.Builder().apply { + setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + setIconResId(if (player.isPlaying) R.drawable.ic_notification_stop_36dp else R.drawable.ic_notification_play_36dp) + setEnabled(true) + }.build() + val seekToNextCommandButton = CommandButton.Builder().apply { + setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + setIconResId(R.drawable.ic_notification_skip_to_next_36dp) + setEnabled(true) + }.build() + val commandButtons: MutableList = mutableListOf( + seekToPreviousCommandButton, + playCommandButton, + seekToNextCommandButton + ) + return ImmutableList.copyOf(commandButtons) + } + } + + + /* + * Player.Listener: Called when one or more player states changed. + */ + private var playerListener: Player.Listener = object : Player.Listener { + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + // store state of playback + val currentMediaId: String = player.currentMediaItem?.mediaId ?: String() + PreferencesHelper.saveIsPlaying(isPlaying) + PreferencesHelper.saveCurrentStationId(currentMediaId) + // reset restart counter + playbackRestartCounter = 0 + // save collection and player state + + collection = CollectionHelper.savePlaybackState( + this@PlayerService, + collection, + currentMediaId, + isPlaying + ) + //updatePlayerState(station, playbackState) + + if (isPlaying) { + // playback is active + } else { + // cancel sleep timer + cancelSleepTimer() + // reset metadata + updateMetadata() + + // playback is not active + // Not playing because playback is paused, ended, suppressed, or the player + // is buffering, stopped or failed. Check player.getPlayWhenReady, + // player.getPlaybackState, player.getPlaybackSuppressionReason and + // player.getPlaybackError for details. + when (player.playbackState) { + // player is able to immediately play from its current position + Player.STATE_READY -> { + // todo + } + // buffering - data needs to be loaded + Player.STATE_BUFFERING -> { + // todo + } + // player finished playing all media + Player.STATE_ENDED -> { + // todo + } + // initial state or player is stopped or playback failed + Player.STATE_IDLE -> { + // todo + } + } + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + if (!playWhenReady) { + when (reason) { + Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> { + // playback reached end: stop / end playback + } + else -> { + // playback has been paused by user or OS: update media session and save state + // PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST or + // PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS or + // PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY or + // PLAY_WHEN_READY_CHANGE_REASON_REMOTE + // handlePlaybackChange(PlaybackStateCompat.STATE_PAUSED) + } + } + } + } + + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + Log.d(TAG, "PlayerError occurred: ${error.errorCodeName}") + // todo: test if playback needs to be restarted + } + + + override fun onMetadata(metadata: Metadata) { + super.onMetadata(metadata) + updateMetadata(AudioHelper.getMetadataString(metadata)) + } + + } + + + /* + * Custom LoadErrorHandlingPolicy that network drop outs + */ + private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() { + override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { + // try to reconnect every 5 seconds - up to 20 times + if (loadErrorInfo.errorCount <= Keys.DEFAULT_MAX_RECONNECTION_COUNT && loadErrorInfo.exception is HttpDataSource.HttpDataSourceException) { + return Keys.RECONNECTION_WAIT_INTERVAL +// } else { +// CoroutineScope(Main).launch { +// player.stop() +// } + } + return C.TIME_UNSET + } + + override fun getMinimumLoadableRetryCount(dataType: Int): Int { + return Int.MAX_VALUE + } + } + + + /* + * Custom receiver that handles Keys.ACTION_COLLECTION_CHANGED + */ + private val collectionChangedReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.hasExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE)) { + val date = Date(intent.getLongExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE, 0L)) + + if (date.after(collection.modificationDate)) { + Log.v(TAG, "PlayerService - reload collection after broadcast received.") + loadCollection(context) + } + } + } + } + + + /* + * Defines the listener for changes in shared preferences + */ + private val sharedPreferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + Keys.PREF_LARGE_BUFFER_SIZE -> { + bufferSizeMultiplier = PreferencesHelper.loadBufferSizeMultiplier() + if (!player.isPlaying && !player.isLoading) { + initializePlayer() + } + } + } + } + + + /* + * Custom AnalyticsListener that enables AudioFX equalizer integration + */ + private val analyticsListener = object : AnalyticsListener { + override fun onAudioSessionIdChanged( + eventTime: AnalyticsListener.EventTime, + audioSessionId: Int + ) { + super.onAudioSessionIdChanged(eventTime, audioSessionId) + // integrate with system equalizer (AudioFX) + val intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + sendBroadcast(intent) + // note: remember to broadcast AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, when not needed anymore + } + } +} diff --git a/app/src/main/java/com/michatec/radio/Radio.kt b/app/src/main/java/com/michatec/radio/Radio.kt new file mode 100644 index 0000000..d3b0957 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/Radio.kt @@ -0,0 +1,45 @@ +/* + * Radio.kt + * Implements the Radio class + * Radio is the base Application class that sets up day and night theme + * + * 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 + +import android.app.Application +import com.michatec.radio.helpers.AppThemeHelper +import com.michatec.radio.helpers.PreferencesHelper +import com.michatec.radio.helpers.PreferencesHelper.initPreferences + + +/** + * Radio.class + */ +class Radio : Application() { + + /* Define log tag */ + private val TAG: String = Radio::class.java.simpleName + + /* Implements onCreate */ + override fun onCreate() { + super.onCreate() + initPreferences() + // set Dark / Light theme state + AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection()) + } + + + /* Implements onTerminate */ + override fun onTerminate() { + super.onTerminate() + } + +} diff --git a/app/src/main/java/com/michatec/radio/SettingsFragment.kt b/app/src/main/java/com/michatec/radio/SettingsFragment.kt new file mode 100644 index 0000000..23fc77a --- /dev/null +++ b/app/src/main/java/com/michatec/radio/SettingsFragment.kt @@ -0,0 +1,574 @@ +/* + * SettingsFragment.kt + * Implements the SettingsFragment fragment + * A SettingsFragment displays the user accessible settings of the app + * + * 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 + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.navigation.fragment.findNavController +import androidx.preference.* +import com.google.android.material.snackbar.Snackbar +import com.michatec.radio.dialogs.ErrorDialog +import com.michatec.radio.dialogs.YesNoDialog +import com.michatec.radio.helpers.* +import com.michatec.radio.helpers.AppThemeHelper.getColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + + +/* + * SettingsFragment class + */ +class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener { + + + /* Define log tag */ + private val TAG: String = SettingsFragment::class.java.simpleName + + /* Overrides onViewCreated from PreferenceFragmentCompat */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // show action bar + (activity as AppCompatActivity).supportActionBar?.show() + (activity as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) + (activity as AppCompatActivity).supportActionBar?.title = getString(R.string.fragment_settings_title) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // above Android Oreo + (activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground) + } else { + val nightMode = AppCompatDelegate.getDefaultNightMode() + if (nightMode == AppCompatDelegate.MODE_NIGHT_YES) { + // night mode is active, set navigation bar color to a suitable color for night mode + (activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground) + } else { + // night mode is not active, set navigation bar color to a suitable color for day mode + (activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireContext(), android.R.color.black) + } + } + } + + /* Overrides onCreatePreferences from PreferenceFragmentCompat */ + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + + val context = preferenceManager.context + val screen = preferenceManager.createPreferenceScreen(context) + + // set up "App Theme" preference + val preferenceThemeSelection = ListPreference(activity as Context) + preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title) + preferenceThemeSelection.setIcon(R.drawable.ic_brush_24dp) + preferenceThemeSelection.key = Keys.PREF_THEME_SELECTION + preferenceThemeSelection.summary = "${getString(R.string.pref_theme_selection_summary)} ${ + AppThemeHelper.getCurrentTheme(activity as Context) + }" + preferenceThemeSelection.entries = arrayOf( + getString(R.string.pref_theme_selection_mode_device_default), + getString(R.string.pref_theme_selection_mode_light), + getString(R.string.pref_theme_selection_mode_dark) + ) + preferenceThemeSelection.entryValues = arrayOf( + Keys.STATE_THEME_FOLLOW_SYSTEM, + Keys.STATE_THEME_LIGHT_MODE, + Keys.STATE_THEME_DARK_MODE + ) + preferenceThemeSelection.setDefaultValue(Keys.STATE_THEME_FOLLOW_SYSTEM) + preferenceThemeSelection.setOnPreferenceChangeListener { preference, newValue -> + if (preference is ListPreference) { + val index: Int = preference.entryValues.indexOf(newValue) + preferenceThemeSelection.summary = + "${getString(R.string.pref_theme_selection_summary)} ${preference.entries[index]}" + return@setOnPreferenceChangeListener true + } else { + return@setOnPreferenceChangeListener false + } + } + + // set up "Update Station Images" preference + val preferenceUpdateStationImages = Preference(activity as Context) + preferenceUpdateStationImages.title = getString(R.string.pref_update_station_images_title) + preferenceUpdateStationImages.setIcon(R.drawable.ic_image_24dp) + preferenceUpdateStationImages.summary = getString(R.string.pref_update_station_images_summary) + preferenceUpdateStationImages.setOnPreferenceClickListener { + // show dialog + YesNoDialog(this).show( + context = activity as Context, + type = Keys.DIALOG_UPDATE_STATION_IMAGES, + message = R.string.dialog_yes_no_message_update_station_images, + yesButton = R.string.dialog_yes_no_positive_button_update_covers + ) + return@setOnPreferenceClickListener true + } + + +// // set up "Update Stations" preference +// val preferenceUpdateCollection: Preference = Preference(activity as Context) +// preferenceUpdateCollection.title = getString(R.string.pref_update_collection_title) +// preferenceUpdateCollection.setIcon(R.drawable.ic_refresh_24dp) +// preferenceUpdateCollection.summary = getString(R.string.pref_update_collection_summary) +// preferenceUpdateCollection.setOnPreferenceClickListener { +// // show dialog +// YesNoDialog(this).show(context = activity as Context, type = Keys.DIALOG_UPDATE_COLLECTION, message = R.string.dialog_yes_no_message_update_collection, yesButton = R.string.dialog_yes_no_positive_button_update_collection) +// return@setOnPreferenceClickListener true +// } + + + // set up "M3U Export" preference + val preferenceM3uExport = Preference(activity as Context) + preferenceM3uExport.title = getString(R.string.pref_m3u_export_title) + preferenceM3uExport.setIcon(R.drawable.ic_save_m3u_24dp) + preferenceM3uExport.summary = getString(R.string.pref_m3u_export_summary) + preferenceM3uExport.setOnPreferenceClickListener { + openSaveM3uDialog() + return@setOnPreferenceClickListener true + } + + + // set up "PLS Export" preference + val preferencePlsExport = Preference(activity as Context) + preferencePlsExport.title = getString(R.string.pref_pls_export_title) + preferencePlsExport.setIcon(R.drawable.ic_save_pls_24dp) + preferencePlsExport.summary = getString(R.string.pref_pls_export_summary) + preferencePlsExport.setOnPreferenceClickListener { + openSavePlsDialog() + return@setOnPreferenceClickListener true + } + + + // set up "Backup Stations" preference + val preferenceBackupCollection = Preference(activity as Context) + preferenceBackupCollection.title = getString(R.string.pref_station_export_title) + preferenceBackupCollection.setIcon(R.drawable.ic_download_24dp) + preferenceBackupCollection.summary = getString(R.string.pref_station_export_summary) + preferenceBackupCollection.setOnPreferenceClickListener { + openBackupCollectionDialog() + return@setOnPreferenceClickListener true + } + + + // set up "Restore Stations" preference + val preferenceRestoreCollection = Preference(activity as Context) + preferenceRestoreCollection.title = getString(R.string.pref_station_restore_title) + preferenceRestoreCollection.setIcon(R.drawable.ic_upload_24dp) + preferenceRestoreCollection.summary = getString(R.string.pref_station_restore_summary) + preferenceRestoreCollection.setOnPreferenceClickListener { + openRestoreCollectionDialog() + return@setOnPreferenceClickListener true + } + + + // set up "Buffer Size" preference + val preferenceBufferSize = SwitchPreferenceCompat(activity as Context) + preferenceBufferSize.title = getString(R.string.pref_buffer_size_title) + preferenceBufferSize.setIcon(R.drawable.ic_network_check_24dp) + preferenceBufferSize.key = Keys.PREF_LARGE_BUFFER_SIZE + preferenceBufferSize.summaryOn = getString(R.string.pref_buffer_size_summary_enabled) + preferenceBufferSize.summaryOff = getString(R.string.pref_buffer_size_summary_disabled) + preferenceBufferSize.setDefaultValue(PreferencesHelper.loadLargeBufferSize()) + + + // set up "Edit Stream Address" preference + val preferenceEnableEditingStreamUri = SwitchPreferenceCompat(activity as Context) + preferenceEnableEditingStreamUri.title = getString(R.string.pref_edit_station_stream_title) + preferenceEnableEditingStreamUri.setIcon(R.drawable.ic_music_note_24dp) + preferenceEnableEditingStreamUri.key = Keys.PREF_EDIT_STREAMS_URIS + preferenceEnableEditingStreamUri.summaryOn = getString(R.string.pref_edit_station_stream_summary_enabled) + preferenceEnableEditingStreamUri.summaryOff = getString(R.string.pref_edit_station_stream_summary_disabled) + preferenceEnableEditingStreamUri.setDefaultValue(PreferencesHelper.loadEditStreamUrisEnabled()) + + + // set up "Edit Stations" preference + val preferenceEnableEditingGeneral = SwitchPreferenceCompat(activity as Context) + preferenceEnableEditingGeneral.title = getString(R.string.pref_edit_station_title) + preferenceEnableEditingGeneral.setIcon(R.drawable.ic_edit_24dp) + preferenceEnableEditingGeneral.key = Keys.PREF_EDIT_STATIONS + preferenceEnableEditingGeneral.summaryOn = getString(R.string.pref_edit_station_summary_enabled) + preferenceEnableEditingGeneral.summaryOff = getString(R.string.pref_edit_station_summary_disabled) + preferenceEnableEditingGeneral.setDefaultValue(PreferencesHelper.loadEditStationsEnabled()) + preferenceEnableEditingGeneral.setOnPreferenceChangeListener { _, newValue -> + when (newValue) { + true -> { + preferenceEnableEditingStreamUri.isEnabled = true + } + false -> { + preferenceEnableEditingStreamUri.isEnabled = false + preferenceEnableEditingStreamUri.isChecked = false + } + } + return@setOnPreferenceChangeListener true + } + + + // set up "App Version" preference + val preferenceAppVersion = Preference(context) + preferenceAppVersion.title = getString(R.string.pref_app_version_title) + preferenceAppVersion.setIcon(R.drawable.ic_info_24dp) + preferenceAppVersion.summary = "${getString(R.string.pref_app_version_summary)} ${BuildConfig.VERSION_NAME} (${getString(R.string.app_version_name)})" + preferenceAppVersion.setOnPreferenceClickListener { + // copy to clipboard + val clip: ClipData = ClipData.newPlainText("simple text", preferenceAppVersion.summary) + val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // since API 33 (TIRAMISU) the OS displays its own notification when content is copied to the clipboard + Snackbar.make(requireView(), R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show() + } + return@setOnPreferenceClickListener true + } + + + // set up "GitHub" preference + val preferenceGitHub = Preference(context) + preferenceGitHub.title = getString(R.string.pref_github_title) + preferenceGitHub.setIcon(R.drawable.ic_github_24dp) + preferenceGitHub.summary = getString(R.string.pref_github_summary) + preferenceGitHub.setOnPreferenceClickListener { + // open web browser + val intent = Intent().apply { + action = Intent.ACTION_VIEW + data = "https://github.com/michatec/Radio".toUri() + } + startActivity(intent) + return@setOnPreferenceClickListener true + } + + // set up "License" preference + val preferenceLicense = Preference(context) + preferenceLicense.title = getString(R.string.pref_license_title) + preferenceLicense.setIcon(R.drawable.ic_library_24dp) + preferenceLicense.summary = getString(R.string.pref_license_summary) + preferenceLicense.setOnPreferenceClickListener { + // open web browser + val intent = Intent().apply { + action = Intent.ACTION_VIEW + data = "https://github.com/michatec/Radio/blob/master/LICENSE.md".toUri() + } + startActivity(intent) + return@setOnPreferenceClickListener true + } + + + // set preference categories + val preferenceCategoryGeneral = PreferenceCategory(activity as Context) + preferenceCategoryGeneral.title = getString(R.string.pref_general_title) + preferenceCategoryGeneral.contains(preferenceThemeSelection) + + val preferenceCategoryMaintenance = PreferenceCategory(activity as Context) + preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title) + preferenceCategoryMaintenance.contains(preferenceUpdateStationImages) +// preferenceCategoryMaintenance.contains(preferenceUpdateCollection) + + val preferenceCategoryImportExport = PreferenceCategory(activity as Context) + preferenceCategoryImportExport.title = getString(R.string.pref_backup_import_export_title) + preferenceCategoryImportExport.contains(preferenceM3uExport) + preferenceCategoryImportExport.contains(preferencePlsExport) + preferenceCategoryImportExport.contains(preferenceBackupCollection) + preferenceCategoryImportExport.contains(preferenceRestoreCollection) + + val preferenceCategoryAdvanced = PreferenceCategory(activity as Context) + preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title) + preferenceCategoryAdvanced.contains(preferenceBufferSize) + preferenceCategoryAdvanced.contains(preferenceEnableEditingGeneral) + preferenceCategoryAdvanced.contains(preferenceEnableEditingStreamUri) + + val preferenceCategoryLinks = PreferenceCategory(context) + preferenceCategoryLinks.title = getString(R.string.pref_links_title) + preferenceCategoryLinks.contains(preferenceAppVersion) + preferenceCategoryLinks.contains(preferenceGitHub) + + + // setup preference screen + screen.addPreference(preferenceAppVersion) + screen.addPreference(preferenceLicense) + screen.addPreference(preferenceCategoryGeneral) + screen.addPreference(preferenceThemeSelection) + screen.addPreference(preferenceCategoryMaintenance) + screen.addPreference(preferenceUpdateStationImages) +// screen.addPreference(preferenceUpdateCollection) + screen.addPreference(preferenceCategoryImportExport) + screen.addPreference(preferenceM3uExport) + screen.addPreference(preferencePlsExport) + screen.addPreference(preferenceBackupCollection) + screen.addPreference(preferenceRestoreCollection) + screen.addPreference(preferenceCategoryAdvanced) + screen.addPreference(preferenceBufferSize) + screen.addPreference(preferenceEnableEditingGeneral) + screen.addPreference(preferenceEnableEditingStreamUri) + screen.addPreference(preferenceCategoryLinks) + screen.addPreference(preferenceGitHub) + preferenceScreen = screen + } + + + /* Overrides onYesNoDialog from YesNoDialogListener */ + override fun onYesNoDialog( + type: Int, + dialogResult: Boolean, + payload: Int, + payloadString: String + ) { + super.onYesNoDialog(type, dialogResult, payload, payloadString) + + when (type) { + Keys.DIALOG_UPDATE_STATION_IMAGES -> { + if (dialogResult) { + // user tapped: refresh station images + updateStationImages() + } + } + + Keys.DIALOG_UPDATE_COLLECTION -> { + if (dialogResult) { + // user tapped update collection + updateCollection() + } + } + } + } + + + /* Register the ActivityResultLauncher for the save m3u dialog */ + private val requestSaveM3uLauncher = + registerForActivityResult(StartActivityForResult(), this::requestSaveM3uResult) + + + /* Register the ActivityResultLauncher for the save pls dialog */ + private val requestSavePlsLauncher = + registerForActivityResult(StartActivityForResult(), this::requestSavePlsResult) + + + /* Register the ActivityResultLauncher for the backup dialog */ + private val requestBackupCollectionLauncher = + registerForActivityResult(StartActivityForResult(), this::requestBackupCollectionResult) + + + /* Register the ActivityResultLauncher for the restore dialog */ + private val requestRestoreCollectionLauncher = + registerForActivityResult(StartActivityForResult(), this::requestRestoreCollectionResult) + + + /* Pass the activity result for the save m3u dialog */ + private fun requestSaveM3uResult(result: ActivityResult) { + // save M3U file to result file location + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val sourceUri: Uri? = FileHelper.getM3ulUri(activity as Activity) + val targetUri: Uri? = result.data?.data + if (targetUri != null && sourceUri != null) { + // copy file async (= fire & forget - no return value needed) + CoroutineScope(IO).launch { + FileHelper.saveCopyOfFileSuspended(activity as Context, sourceUri, targetUri) + } + Snackbar.make(requireView(), R.string.toastmessage_save_m3u, Snackbar.LENGTH_LONG).show() + } else { + Log.w(TAG, "M3U export failed.") + } + } + } + + + /* Pass the activity result for the save pls dialog */ + private fun requestSavePlsResult(result: ActivityResult) { + // save PLS file to result file location + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val sourceUri: Uri? = FileHelper.getPlslUri(activity as Activity) + val targetUri: Uri? = result.data?.data + if (targetUri != null && sourceUri != null) { + // copy file async (= fire & forget - no return value needed) + CoroutineScope(IO).launch { + FileHelper.saveCopyOfFileSuspended(activity as Context, sourceUri, targetUri) + } + Snackbar.make(requireView(), R.string.toastmessage_save_pls, Snackbar.LENGTH_LONG).show() + } else { + Log.w(TAG, "PLS export failed.") + } + } + } + + + /* Pass the activity result for the backup collection dialog */ + private fun requestBackupCollectionResult(result: ActivityResult) { + // save station backup file to result file location + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val targetUri: Uri? = result.data?.data + if (targetUri != null) { + BackupHelper.backup(requireView(), activity as Context, targetUri) + Log.e(TAG, "Backing up to $targetUri") + } else { + Log.w(TAG, "Station backup failed.") + } + } + } + + + /* Pass the activity result for the restore collection dialog */ + private fun requestRestoreCollectionResult(result: ActivityResult) { + // save station backup file to result file location + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val sourceUri: Uri? = result.data?.data + if (sourceUri != null) { + // open and import OPML in player fragment + val bundle: Bundle = bundleOf( + Keys.ARG_RESTORE_COLLECTION to "$sourceUri" + ) + this.findNavController().navigate(R.id.player_destination, bundle) + } + } + } + + + /* Updates collection */ + private fun updateCollection() { + if (NetworkHelper.isConnectedToNetwork(activity as Context)) { + Snackbar.make( + requireView(), + R.string.toastmessage_updating_collection, + Snackbar.LENGTH_LONG + ).show() + // update collection in player screen + val bundle: Bundle = bundleOf(Keys.ARG_UPDATE_COLLECTION to true) + this.findNavController().navigate(R.id.player_destination, bundle) + } else { + ErrorDialog().show( + activity as Context, + R.string.dialog_error_title_no_network, + R.string.dialog_error_message_no_network + ) + } + } + + + /* Updates station images */ + private fun updateStationImages() { + if (NetworkHelper.isConnectedToNetwork(activity as Context)) { + Snackbar.make( + requireView(), + R.string.toastmessage_updating_station_images, + Snackbar.LENGTH_LONG + ).show() + // update collection in player screen + val bundle: Bundle = bundleOf( + Keys.ARG_UPDATE_IMAGES to true + ) + this.findNavController().navigate(R.id.player_destination, bundle) + } else { + ErrorDialog().show( + activity as Context, + R.string.dialog_error_title_no_network, + R.string.dialog_error_message_no_network + ) + } + } + + + /* Opens up a file picker to select the save location */ + private fun openSaveM3uDialog() { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = Keys.MIME_TYPE_M3U + + val timeStamp: String + val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US) + timeStamp = dateFormat.format(Date()) + + putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.m3u") + } + // file gets saved in the ActivityResult + try { + requestSaveM3uLauncher.launch(intent) + } catch (exception: Exception) { + Log.e(TAG, "Unable to save M3U.\n$exception") + Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show() + } + } + + + /* Opens up a file picker to select the save location */ + private fun openSavePlsDialog() { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = Keys.MIME_TYPE_PLS + + val timeStamp: String + val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US) + timeStamp = dateFormat.format(Date()) + + putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.pls") + } + // file gets saved in the ActivityResult + try { + requestSavePlsLauncher.launch(intent) + } catch (exception: Exception) { + Log.e(TAG, "Unable to save PLS.\n$exception") + Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show() + } + } + + + /* Opens up a file picker to select the backup location */ + private fun openBackupCollectionDialog() { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = Keys.MIME_TYPE_ZIP + + val timeStamp: String + val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US) + timeStamp = dateFormat.format(Date()) + + putExtra(Intent.EXTRA_TITLE, "URL_Radio$timeStamp.zip") + } + // file gets saved in the ActivityResult + try { + requestBackupCollectionLauncher.launch(intent) + } catch (exception: Exception) { + Log.e(TAG, "Unable to save M3U.\n$exception") + Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show() + } + } + + + /* Opens up a file picker to select the file containing the collection to be restored */ + private fun openRestoreCollectionDialog() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, Keys.MIME_TYPES_ZIP) + } + // file gets saved in the ActivityResult + try { + requestRestoreCollectionLauncher.launch(intent) + } catch (exception: Exception) { + Log.e(TAG, "Unable to open file picker for ZIP.\n$exception") + // Toast.makeText(activity as Context, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show() + } + } +} diff --git a/app/src/main/java/com/michatec/radio/collection/CollectionAdapter.kt b/app/src/main/java/com/michatec/radio/collection/CollectionAdapter.kt new file mode 100644 index 0000000..7b01a08 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/collection/CollectionAdapter.kt @@ -0,0 +1,748 @@ +/* + * CollectionAdapter.kt + * Implements the CollectionAdapter class + * A CollectionAdapter is a custom adapter providing station card 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.collection + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.cardview.widget.CardView +import androidx.constraintlayout.widget.Group +import androidx.core.net.toUri +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.findNavController +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import com.google.android.material.textfield.TextInputEditText +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.helpers.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import java.util.* + + +/* + * CollectionAdapter class + */ +class CollectionAdapter( + private val context: Context, + private val collectionAdapterListener: CollectionAdapterListener +) : RecyclerView.Adapter(), UpdateHelper.UpdateHelperListener { + + /* Main class variables */ + private lateinit var collectionViewModel: CollectionViewModel + private var collection: Collection = Collection() + private var editStationsEnabled: Boolean = PreferencesHelper.loadEditStationsEnabled() + private var editStationStreamsEnabled: Boolean = PreferencesHelper.loadEditStreamUrisEnabled() + private var expandedStationUuid: String = PreferencesHelper.loadStationListStreamUuid() + private var expandedStationPosition: Int = -1 + var isExpandedForEdit: Boolean = false + + + /* Listener Interface */ + interface CollectionAdapterListener { + fun onPlayButtonTapped(stationUuid: String) + fun onAddNewButtonTapped() + fun onChangeImageButtonTapped(stationUuid: String) + } + + + /* Overrides onAttachedToRecyclerView */ + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + // create view model and observe changes in collection view model + collectionViewModel = + ViewModelProvider(context as AppCompatActivity)[CollectionViewModel::class.java] + observeCollectionViewModel(context as LifecycleOwner) + // start listening for changes in shared preferences + PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener) + } + + + /* Overrides onDetachedFromRecyclerView */ + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + // stop listening for changes in shared preferences + PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener) + } + + + /* Overrides onCreateViewHolder */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + + return when (viewType) { + Keys.VIEW_TYPE_ADD_NEW -> { + // get view, put view into holder and return + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.card_add_new_station, parent, false) + AddNewViewHolder(v) + } + else -> { + // get view, put view into holder and return + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.card_station, parent, false) + StationViewHolder(v) + } + } + } + + + /* Implement the method to handle item move */ + fun onItemMove(fromPosition: Int, toPosition: Int) { + // Do nothing if in "edit" mode + if (isExpandedForEdit) { + return + } + + val stationList = collection.stations + val stationCount = stationList.size + + if (fromPosition !in 0 until stationCount || toPosition !in 0 until stationCount) { + return + } + + val fromStation = stationList[fromPosition] + val toStation = stationList[toPosition] + + if (fromStation.starred != toStation.starred) { + // Prevent moving a starred item into non-starred area or vice versa + return + } + + // Move within the same group (either starred or non-starred) + Collections.swap(stationList, fromPosition, toPosition) + + // Update the value of expandedStationPosition if necessary + expandedStationPosition = if (fromPosition == expandedStationPosition) toPosition else expandedStationPosition + + // Notify the adapter about the item move + notifyItemMoved(fromPosition, toPosition) + } + + + /* Implement the method to handle item dismissal */ + fun onItemDismiss(position: Int) { + // Remove the item at the given position from your data collection + collection.stations.removeAt(position) + notifyItemRemoved(position) + } + + + /* Method for saving the collection after the drag-and-drop operation */ + fun saveCollectionAfterDragDrop() { + // Save the collection after the dragging is completed + CollectionHelper.saveCollection(context, collection) + } + + + /* Overrides onBindViewHolder */ + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + // CASE ADD NEW CARD + is AddNewViewHolder -> { + // get reference to StationViewHolder + val addNewViewHolder: AddNewViewHolder = holder + addNewViewHolder.addNewStationView.setOnClickListener { + // show the add station dialog + collectionAdapterListener.onAddNewButtonTapped() + } + addNewViewHolder.settingsButtonView.setOnClickListener { + it.findNavController().navigate(R.id.settings_destination) + } + } + // CASE STATION CARD + is StationViewHolder -> { + // get station from position + val station: Station = collection.stations[position] + + // get reference to StationViewHolder + val stationViewHolder: StationViewHolder = holder + + // set up station views + setStarredIcon(stationViewHolder, station) + setStationName(stationViewHolder, station) + setStationImage(stationViewHolder, station) + setStationButtons(stationViewHolder, station) + setEditViews(stationViewHolder, station) + + // show / hide edit views + when (expandedStationPosition) { + // show edit views + position -> { + stationViewHolder.stationNameView.isVisible = false + stationViewHolder.playButtonView.isGone = true + stationViewHolder.stationStarredView.isGone = true + stationViewHolder.editViews.isVisible = true + if (editStationStreamsEnabled) { + stationViewHolder.stationUriEditView.isVisible = true + stationViewHolder.stationUriEditView.imeOptions = + EditorInfo.IME_ACTION_DONE + } else { + stationViewHolder.stationUriEditView.isGone = true + stationViewHolder.stationNameEditView.imeOptions = + EditorInfo.IME_ACTION_DONE + } + } + // hide edit views + else -> { + stationViewHolder.stationNameView.isVisible = true + //stationViewHolder.playButtonView.isVisible = true + stationViewHolder.stationStarredView.isVisible = station.starred + stationViewHolder.editViews.isGone = true + stationViewHolder.stationUriEditView.isGone = true + } + } + } + } + } + + + /* Overrides onStationUpdated from UpdateHelperListener */ + override fun onStationUpdated( + collection: Collection, + positionPriorUpdate: Int, + positionAfterUpdate: Int + ) { + // check if position has changed after update and move stations around if necessary + if (positionPriorUpdate != positionAfterUpdate && positionPriorUpdate != -1 && positionAfterUpdate != -1) { + notifyItemMoved(positionPriorUpdate, positionAfterUpdate) + notifyItemChanged(positionPriorUpdate) + } + // update station (e.g. name) + notifyItemChanged(positionAfterUpdate) + } + + + /* Sets the station name view */ + private fun setStationName(stationViewHolder: StationViewHolder, station: Station) { + stationViewHolder.stationNameView.text = station.name + } + + + /* Sets the edit views */ + private fun setEditViews(stationViewHolder: StationViewHolder, station: Station) { + stationViewHolder.stationNameEditView.setText(station.name, TextView.BufferType.EDITABLE) + stationViewHolder.stationUriEditView.setText( + station.getStreamUri(), + TextView.BufferType.EDITABLE + ) + stationViewHolder.stationUriEditView.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + handleStationUriInput(stationViewHolder, s, station.getStreamUri()) + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + stationViewHolder.cancelButton.setOnClickListener { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView) + } + stationViewHolder.saveButton.setOnClickListener { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + saveStation( + station, + position, + stationViewHolder.stationNameEditView.text.toString(), + stationViewHolder.stationUriEditView.text.toString() + ) + UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView) + } + stationViewHolder.placeOnHomeScreenButton.setOnClickListener { + val position: Int = stationViewHolder.adapterPosition + ShortcutHelper.placeShortcut(context, station) + toggleEditViews(position, station.uuid) + UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView) + } + stationViewHolder.stationImageChangeView.setOnClickListener { + val position: Int = stationViewHolder.adapterPosition + collectionAdapterListener.onChangeImageButtonTapped(station.uuid) + stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView) + } + } + + + /* Shows / hides the edit view for a station */ + private fun toggleEditViews(position: Int, stationUuid: String) { + when (stationUuid) { + // CASE: this station's edit view is already expanded + expandedStationUuid -> { + isExpandedForEdit = false + // reset currently expanded info (both uuid and position) + saveStationListExpandedState() + // update station view + notifyItemChanged(position) + } + // CASE: this station's edit view is not yet expanded + else -> { + isExpandedForEdit = true + // remember previously expanded position + val previousExpandedStationPosition: Int = expandedStationPosition + // if station was expanded - collapse it + if (previousExpandedStationPosition > -1 && previousExpandedStationPosition < collection.stations.size) + notifyItemChanged(previousExpandedStationPosition) + // store current station as the expanded one + saveStationListExpandedState(position, stationUuid) + // update station view + notifyDataSetChanged() + } + } + } + + + /* Toggles the starred icon */ + private fun setStarredIcon(stationViewHolder: StationViewHolder, station: Station) { + when (station.starred) { + true -> { + if (station.imageColor != -1) { + // stationViewHolder.stationCardView.setCardBackgroundColor(station.imageColor) + stationViewHolder.stationStarredView.setColorFilter(station.imageColor) + } + stationViewHolder.stationStarredView.isVisible = true + } + false -> stationViewHolder.stationStarredView.isGone = true + } + } + + + /* Sets the station image view */ + private fun setStationImage(stationViewHolder: StationViewHolder, station: Station) { + if (station.imageColor != -1) { + stationViewHolder.stationImageView.setBackgroundColor(station.imageColor) + } + stationViewHolder.stationImageView.setImageBitmap( + ImageHelper.getStationImage( + context, + station.smallImage + ) + ) + stationViewHolder.stationImageView.contentDescription = + "${context.getString(R.string.descr_player_station_image)}: ${station.name}" + } + + + /* Sets up a station's play and edit buttons */ + private fun setStationButtons(stationViewHolder: StationViewHolder, station: Station) { + when (station.isPlaying) { + true -> stationViewHolder.playButtonView.visibility = View.VISIBLE + false -> stationViewHolder.playButtonView.visibility = View.INVISIBLE + } + stationViewHolder.stationCardView.setOnClickListener { + collectionAdapterListener.onPlayButtonTapped(station.uuid) + } + stationViewHolder.playButtonView.setOnClickListener { + collectionAdapterListener.onPlayButtonTapped(station.uuid) + } + stationViewHolder.stationNameView.setOnClickListener { + collectionAdapterListener.onPlayButtonTapped(station.uuid) + } + stationViewHolder.stationStarredView.setOnClickListener { + collectionAdapterListener.onPlayButtonTapped(station.uuid) + } + stationViewHolder.stationImageView.setOnClickListener { + collectionAdapterListener.onPlayButtonTapped(station.uuid) + } + stationViewHolder.playButtonView.setOnLongClickListener { + if (editStationsEnabled) { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + return@setOnLongClickListener true + } else { + return@setOnLongClickListener false + } + } + stationViewHolder.stationNameView.setOnLongClickListener { + if (editStationsEnabled) { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + return@setOnLongClickListener true + } else { + return@setOnLongClickListener false + } + } + stationViewHolder.stationStarredView.setOnLongClickListener { + if (editStationsEnabled) { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + return@setOnLongClickListener true + } else { + return@setOnLongClickListener false + } + } + stationViewHolder.stationImageView.setOnLongClickListener { + if (editStationsEnabled) { + val position: Int = stationViewHolder.adapterPosition + toggleEditViews(position, station.uuid) + return@setOnLongClickListener true + } else { + return@setOnLongClickListener false + } + } + } + + + /* Checks if stream uri input is valid */ + private fun handleStationUriInput( + stationViewHolder: StationViewHolder, + s: Editable?, + streamUri: String + ) { + if (editStationStreamsEnabled) { + val input: String = s.toString() + if (input == streamUri) { + // enable save button + stationViewHolder.saveButton.isEnabled = true + } else { + // 1. disable save button + stationViewHolder.saveButton.isEnabled = false + // 2. check for valid station uri - and re-enable button + if (input.startsWith("http")) { + // detect content type on background thread + CoroutineScope(IO).launch { + val deferred: Deferred = + async(Dispatchers.Default) { + NetworkHelper.detectContentTypeSuspended(input) + } + // wait for result + val contentType: String = + deferred.await().type.lowercase(Locale.getDefault()) + // CASE: stream address detected + 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) + ) { + // re-enable save button + withContext(Main) { + stationViewHolder.saveButton.isEnabled = true + } + } + } + } + } + } + } + + + /* Overrides onBindViewHolder */ + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + + if (payloads.isEmpty()) { + // call regular onBindViewHolder method + onBindViewHolder(holder, position) + + } else if (holder is StationViewHolder) { + // get station from position + collection.stations[holder.getAdapterPosition()] + + for (data in payloads) { + when (data as Int) { + Keys.HOLDER_UPDATE_COVER -> { + // todo implement + } + Keys.HOLDER_UPDATE_NAME -> { + // todo implement + } + Keys.HOLDER_UPDATE_PLAYBACK_STATE -> { + // todo implement + } + Keys.HOLDER_UPDATE_PLAYBACK_PROGRESS -> { + // todo implement + } + Keys.HOLDER_UPDATE_DOWNLOAD_STATE -> { + // todo implement + } + } + } + } + } + + + /* Overrides getItemViewType */ + override fun getItemViewType(position: Int): Int { + return when (isPositionFooter(position)) { + true -> Keys.VIEW_TYPE_ADD_NEW + false -> Keys.VIEW_TYPE_STATION + } + } + + + /* Overrides getItemCount */ + override fun getItemCount(): Int { + // +1 ==> the add station card + return collection.stations.size + 1 + } + + + /* Removes a station from collection */ + fun removeStation(context: Context, position: Int) { + val newCollection = collection.deepCopy() + // delete images assets + CollectionHelper.deleteStationImages(context, newCollection.stations[position]) + // remove station from collection + newCollection.stations.removeAt(position) + collection = newCollection + // update list + notifyItemRemoved(position) + // save collection and broadcast changes + CollectionHelper.saveCollection(context, newCollection) + } + + + /* Toggles starred status of a station */ + fun toggleStarredStation(context: Context, position: Int) { + // update view (reset "swipe" state of station card) + notifyItemChanged(position) + // mark starred + val stationUuid: String = collection.stations[position].uuid + collection.stations[position].apply { starred = !starred } + // sort collection + collection = CollectionHelper.sortCollection(collection) + // update list + notifyItemMoved(position, CollectionHelper.getStationPosition(collection, stationUuid)) + // save collection and broadcast changes + CollectionHelper.saveCollection(context, collection) + } + + + /* Saves edited station */ + private fun saveStation( + station: Station, + position: Int, + stationName: String, + streamUri: String + ) { + // update station name and stream uri + collection.stations.forEach { + if (it.uuid == station.uuid) { + if (stationName.isNotEmpty()) { + it.name = stationName + it.nameManuallySet = true + } + if (streamUri.isNotEmpty()) { + it.streamUris[0] = streamUri + } + } + } + // sort and save collection + collection = CollectionHelper.sortCollection(collection) + // update list + val newPosition: Int = CollectionHelper.getStationPosition(collection, station.uuid) + if (position != newPosition && newPosition != -1) { + notifyItemMoved(position, newPosition) + notifyItemChanged(position) + } + // save collection and broadcast changes + CollectionHelper.saveCollection(context, collection) + } + + +// /* Initiates update of a station's information */ // todo move to CollectionHelper +// private fun updateStation(context: Context, station: Station) { +// if (station.radioBrowserStationUuid.isNotEmpty()) { +// // get updated station from radio browser - results are handled by onRadioBrowserSearchResults +// val radioBrowserSearch: RadioBrowserSearch = RadioBrowserSearch(context, this) +// radioBrowserSearch.searchStation(context, station.radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID) +// } else if (station.remoteStationLocation.isNotEmpty()) { +// // download playlist // todo check content type detection is necessary here +// DownloadHelper.downloadPlaylists(context, arrayOf(station.remoteStationLocation)) +// } else { +// Log.w(TAG, "Unable to update station: ${station.name}.") +// } +// } + + + /* Determines if position is last */ + private fun isPositionFooter(position: Int): Boolean { + return position == collection.stations.size + } + + + /* Updates the station list - redraws the views with changed content */ + @SuppressLint("NotifyDataSetChanged") + private fun updateRecyclerView(oldCollection: Collection, newCollection: Collection) { + collection = newCollection + if (oldCollection.stations.size == 0 && newCollection.stations.size > 0) { + // data set has been initialized - redraw the whole list + notifyDataSetChanged() + } else { + // calculate differences between current collection and new collection - and inform this adapter about the changes + val diffResult = + DiffUtil.calculateDiff(CollectionDiffCallback(oldCollection, newCollection), true) + diffResult.dispatchUpdatesTo(this@CollectionAdapter) + } + } + + + /* Updates and saves state of expanded station edit view in list */ + private fun saveStationListExpandedState( + position: Int = -1, + stationStreamUri: String = String() + ) { + expandedStationUuid = stationStreamUri + expandedStationPosition = position + PreferencesHelper.saveStationListStreamUuid(expandedStationUuid) + } + + + /* Observe view model of station collection*/ + private fun observeCollectionViewModel(owner: LifecycleOwner) { + collectionViewModel.collectionLiveData.observe(owner) { newCollection -> + updateRecyclerView(collection, newCollection) + } + } + + + /* + * Defines the listener for changes in shared preferences + */ + private val sharedPreferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + Keys.PREF_EDIT_STATIONS -> editStationsEnabled = + PreferencesHelper.loadEditStationsEnabled() + Keys.PREF_EDIT_STREAMS_URIS -> editStationStreamsEnabled = + PreferencesHelper.loadEditStreamUrisEnabled() + } + } + /* + * End of declaration + */ + + + /* + * Inner class: ViewHolder for the Add New Station action + */ + private inner class AddNewViewHolder(listItemAddNewLayout: View) : + RecyclerView.ViewHolder(listItemAddNewLayout) { + val addNewStationView: ExtendedFloatingActionButton = + listItemAddNewLayout.findViewById(R.id.card_add_new_station) + val settingsButtonView: ExtendedFloatingActionButton = + listItemAddNewLayout.findViewById(R.id.card_settings) + } + /* + * End of inner class + */ + + + /* + * Inner class: ViewHolder for a station + */ + private inner class StationViewHolder(stationCardLayout: View) : + RecyclerView.ViewHolder(stationCardLayout) { + val stationCardView: CardView = stationCardLayout.findViewById(R.id.station_card) + val stationImageView: ImageView = stationCardLayout.findViewById(R.id.station_icon) + val stationNameView: TextView = stationCardLayout.findViewById(R.id.station_name) + val stationStarredView: ImageView = stationCardLayout.findViewById(R.id.starred_icon) + + // val menuButtonView: ImageView = stationCardLayout.findViewById(R.id.menu_button) + val playButtonView: ImageView = stationCardLayout.findViewById(R.id.playback_button) + val editViews: Group = stationCardLayout.findViewById(R.id.default_edit_views) + val stationImageChangeView: ImageView = + stationCardLayout.findViewById(R.id.change_image_view) + val stationNameEditView: TextInputEditText = + stationCardLayout.findViewById(R.id.edit_station_name) + val stationUriEditView: TextInputEditText = + stationCardLayout.findViewById(R.id.edit_stream_uri) + val placeOnHomeScreenButton: MaterialButton = + stationCardLayout.findViewById(R.id.place_on_home_screen_button) + val cancelButton: MaterialButton = stationCardLayout.findViewById(R.id.cancel_button) + val saveButton: MaterialButton = stationCardLayout.findViewById(R.id.save_button) + } + /* + * End of inner class + */ + + + /* + * Inner class: DiffUtil.Callback that determines changes in data - improves list performance + */ + private inner class CollectionDiffCallback( + val oldCollection: Collection, + val newCollection: Collection + ) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldStation: Station = oldCollection.stations[oldItemPosition] + val newStation: Station = newCollection.stations[newItemPosition] + return oldStation.uuid == newStation.uuid + } + + override fun getOldListSize(): Int { + return oldCollection.stations.size + } + + override fun getNewListSize(): Int { + return newCollection.stations.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldStation: Station = oldCollection.stations[oldItemPosition] + val newStation: Station = newCollection.stations[newItemPosition] + + // compare relevant contents of a station + if (oldStation.isPlaying != newStation.isPlaying) return false + if (oldStation.uuid != newStation.uuid) return false + if (oldStation.starred != newStation.starred) return false + if (oldStation.name != newStation.name) return false + if (oldStation.stream != newStation.stream) return false + if (oldStation.remoteImageLocation != newStation.remoteImageLocation) return false + if (oldStation.remoteStationLocation != newStation.remoteStationLocation) return false + if (!oldStation.streamUris.containsAll(newStation.streamUris)) return false + if (oldStation.imageColor != newStation.imageColor) return false + if (FileHelper.getFileSize(context, oldStation.image.toUri()) != FileHelper.getFileSize( + context, + newStation.image.toUri() + ) + ) return false + if (FileHelper.getFileSize( + context, + oldStation.smallImage.toUri() + ) != FileHelper.getFileSize(context, newStation.smallImage.toUri()) + ) return false + + // none of the above -> contents are the same + return true + } + } + /* + * End of inner class + */ +} diff --git a/app/src/main/java/com/michatec/radio/collection/CollectionViewModel.kt b/app/src/main/java/com/michatec/radio/collection/CollectionViewModel.kt new file mode 100644 index 0000000..8a78fca --- /dev/null +++ b/app/src/main/java/com/michatec/radio/collection/CollectionViewModel.kt @@ -0,0 +1,106 @@ +/* + * CollectionViewModel.kt + * Implements the CollectionViewModel class + * A CollectionViewModel stores the collection of stations as live data + * + * 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.collection + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.michatec.radio.Keys +import com.michatec.radio.core.Collection +import com.michatec.radio.helpers.FileHelper +import kotlinx.coroutines.launch +import java.util.* + + +/* + * CollectionViewModel.class + */ +class CollectionViewModel(application: Application) : AndroidViewModel(application) { + + /* Define log tag */ + private val TAG: String = CollectionViewModel::class.java.simpleName + + /* Main class variables */ + val collectionLiveData: MutableLiveData = MutableLiveData() + val collectionSizeLiveData: MutableLiveData = MutableLiveData() + private var modificationDateViewModel: Date = Date() + private var collectionChangedReceiver: BroadcastReceiver + + + /* Init constructor */ + init { + // load collection + loadCollection() + // create and register collection changed receiver + collectionChangedReceiver = createCollectionChangedReceiver() + LocalBroadcastManager.getInstance(application).registerReceiver( + collectionChangedReceiver, + IntentFilter(Keys.ACTION_COLLECTION_CHANGED) + ) + } + + + /* Overrides onCleared */ + override fun onCleared() { + super.onCleared() + LocalBroadcastManager.getInstance(getApplication()) + .unregisterReceiver(collectionChangedReceiver) + } + + + /* Creates the collectionChangedReceiver - handles Keys.ACTION_COLLECTION_CHANGED */ + private fun createCollectionChangedReceiver(): BroadcastReceiver { + return object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.hasExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE)) { + val date = + Date(intent.getLongExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE, 0L)) + // check if reload is necessary + if (date.after(modificationDateViewModel)) { + Log.v( + TAG, + "CollectionViewModel - reload collection after broadcast received." + ) + loadCollection() + } + } + } + } + } + + + /* Reads collection of radio stations from storage using GSON */ + private fun loadCollection() { + Log.v(TAG, "Loading collection of stations from storage") + viewModelScope.launch { + // load collection on background thread + val collection: Collection = FileHelper.readCollectionSuspended(getApplication()) + // get updated modification date + modificationDateViewModel = collection.modificationDate + // update collection view model + collectionLiveData.value = collection + // update collection sie + collectionSizeLiveData.value = collection.stations.size + } + } + +} diff --git a/app/src/main/java/com/michatec/radio/core/Collection.kt b/app/src/main/java/com/michatec/radio/core/Collection.kt new file mode 100644 index 0000000..fcb2eea --- /dev/null +++ b/app/src/main/java/com/michatec/radio/core/Collection.kt @@ -0,0 +1,58 @@ +/* + * Collection.kt + * Implements the Collection class + * A Collection object holds a list of radio 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.core + +import android.os.Parcelable +import androidx.annotation.Keep +import com.google.gson.annotations.Expose +import com.michatec.radio.Keys +import kotlinx.parcelize.Parcelize +import java.util.* + + +/* + * Collection class + */ +@Keep +@Parcelize +data class Collection( + @Expose val version: Int = Keys.CURRENT_COLLECTION_CLASS_VERSION_NUMBER, + @Expose var stations: MutableList = mutableListOf(), + @Expose var modificationDate: Date = Date() +) : Parcelable { + + /* overrides toString method */ + override fun toString(): String { + val stringBuilder: StringBuilder = StringBuilder() + stringBuilder.append("Format version: $version\n") + stringBuilder.append("Number of stations in collection: ${stations.size}\n\n") + stations.forEach { + stringBuilder.append("$it\n") + } + return stringBuilder.toString() + } + + + /* Creates a deep copy of a Collection */ + fun deepCopy(): Collection { + val stationsCopy: MutableList = mutableListOf() + stations.forEach { stationsCopy.add(it.deepCopy()) } + return Collection( + version = version, + stations = stationsCopy, + modificationDate = modificationDate + ) + } + +} diff --git a/app/src/main/java/com/michatec/radio/core/Station.kt b/app/src/main/java/com/michatec/radio/core/Station.kt new file mode 100644 index 0000000..34e1221 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/core/Station.kt @@ -0,0 +1,107 @@ +/* + * Station.kt + * Implements the Station class + * A Station object holds the base data of a radio + * + * 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.core + +import android.os.Parcelable +import androidx.annotation.Keep +import com.google.gson.annotations.Expose +import com.michatec.radio.Keys +import kotlinx.parcelize.Parcelize +import java.util.Date +import java.util.UUID + + +/* + * Station class + */ +@Keep +@Parcelize +data class Station( + @Expose val uuid: String = UUID.randomUUID().toString(), + @Expose var starred: Boolean = false, + @Expose var name: String = String(), + @Expose var nameManuallySet: Boolean = false, + @Expose var streamUris: MutableList = mutableListOf(), + @Expose var stream: Int = 0, + @Expose var streamContent: String = Keys.MIME_TYPE_UNSUPPORTED, + @Expose var homepage: String = String(), + @Expose var image: String = String(), + @Expose var smallImage: String = String(), + @Expose var imageColor: Int = -1, + @Expose var imageManuallySet: Boolean = false, + @Expose var remoteImageLocation: String = String(), + @Expose var remoteStationLocation: String = String(), + @Expose var modificationDate: Date = Keys.DEFAULT_DATE, + @Expose var isPlaying: Boolean = false, + @Expose var radioBrowserStationUuid: String = String(), + @Expose var radioBrowserChangeUuid: String = String(), + @Expose var bitrate: Int = 0, + @Expose var codec: String = String() +) : Parcelable { + + + /* overrides toString method */ + override fun toString(): String { + val stringBuilder: StringBuilder = StringBuilder() + stringBuilder.append("Name: ${name}\n") + if (streamUris.isNotEmpty()) stringBuilder.append("Stream: ${streamUris[stream]}\n") + stringBuilder.append("Last Update: ${modificationDate}\n") + stringBuilder.append("Content-Type: ${streamContent}\n") + return stringBuilder.toString() + } + + + /* Getter for currently selected stream */ + fun getStreamUri(): String { + return if (streamUris.isNotEmpty()) { + streamUris[stream] + } else { + String() + } + } + + + /* Checks if a Station has the minimum required elements / data */ + fun isValid(): Boolean { + return uuid.isNotEmpty() && name.isNotEmpty() && streamUris.isNotEmpty() && streamUris[stream].isNotEmpty() && modificationDate != Keys.DEFAULT_DATE && streamContent != Keys.MIME_TYPE_UNSUPPORTED + } + + + /* Creates a deep copy of a Station */ + fun deepCopy(): Station { + return Station( + uuid = uuid, + starred = starred, + name = name, + nameManuallySet = nameManuallySet, + streamUris = streamUris, + stream = stream, + streamContent = streamContent, + homepage = homepage, + image = image, + smallImage = smallImage, + imageColor = imageColor, + imageManuallySet = imageManuallySet, + remoteImageLocation = remoteImageLocation, + remoteStationLocation = remoteStationLocation, + modificationDate = modificationDate, + isPlaying = isPlaying, + radioBrowserStationUuid = radioBrowserStationUuid, + radioBrowserChangeUuid = radioBrowserChangeUuid, + bitrate = bitrate, + codec = codec + ) + } +} diff --git a/app/src/main/java/com/michatec/radio/dialogs/AddStationDialog.kt b/app/src/main/java/com/michatec/radio/dialogs/AddStationDialog.kt new file mode 100644 index 0000000..740ce95 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/dialogs/AddStationDialog.kt @@ -0,0 +1,133 @@ +/* + * AddStationDialog.kt + * Implements the AddStationDialog class + * A AddStationDialog shows a dialog with list of stations to import + * + * 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.dialogs + +import android.content.Context +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.michatec.radio.R +import com.michatec.radio.core.Station +import com.michatec.radio.search.SearchResultAdapter + + +/* + * AddStationDialog class + */ +class AddStationDialog ( + private val context: Context, + private val stationList: List, + private val listener: AddStationDialogListener) : + SearchResultAdapter.SearchResultAdapterListener { + + + /* Interface used to communicate back to activity */ + interface AddStationDialogListener { + fun onAddStationDialog(station: Station) + } + + + /* Define log tag */ + private val TAG = AddStationDialog::class.java.simpleName + + + /* Main class variables */ + private lateinit var dialog: AlertDialog + private lateinit var stationSearchResultList: RecyclerView + private lateinit var searchResultAdapter: SearchResultAdapter + private var station: Station = Station() + + + /* Overrides onSearchResultTapped from SearchResultAdapterListener */ + override fun onSearchResultTapped(result: Station) { + station = result + // make add button clickable + activateAddButton() + } + + + /* Construct and show dialog */ + fun show() { + // prepare dialog builder + val builder = MaterialAlertDialogBuilder(context) + + // set title + builder.setTitle(R.string.dialog_add_station_title) + + // get views + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.dialog_add_station, null) + stationSearchResultList = view.findViewById(R.id.station_list) + + // set up list of search results + setupRecyclerView(context) + + // add okay ("Add") button + builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ -> + // listen for click on add button + listener.onAddStationDialog(station) + searchResultAdapter.stopPrePlayback() + } + // add cancel button + builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ -> + searchResultAdapter.stopPrePlayback() + } + // handle outside-click as "no" + builder.setOnCancelListener { + searchResultAdapter.stopPrePlayback() + } + + // set dialog view + builder.setView(view) + + // create and display dialog + dialog = builder.create() + dialog.show() + + // initially disable "Add" button + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + + + /* Sets up list of results (RecyclerView) */ + private fun setupRecyclerView(context: Context) { + searchResultAdapter = SearchResultAdapter(this, stationList) + stationSearchResultList.adapter = searchResultAdapter + val layoutManager: LinearLayoutManager = object: LinearLayoutManager(context) { + override fun supportsPredictiveItemAnimations(): Boolean { + return true + } + } + stationSearchResultList.layoutManager = layoutManager + stationSearchResultList.itemAnimator = DefaultItemAnimator() + } + + + /* Implement activateAddButton to enable the "Add" button */ + override fun activateAddButton() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } + + + /* Implement deactivateAddButton to disable the "Add" button */ + override fun deactivateAddButton() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/michatec/radio/dialogs/ErrorDialog.kt b/app/src/main/java/com/michatec/radio/dialogs/ErrorDialog.kt new file mode 100644 index 0000000..df8cf88 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/dialogs/ErrorDialog.kt @@ -0,0 +1,91 @@ +/* + * ErrorDialog.kt + * Implements the ErrorDialog class + * An ErrorDialog shows an error dialog with details + * + * 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.dialogs + +import android.content.Context +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.core.view.isGone +import androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.michatec.radio.R + + +/* + * ErrorDialog class + */ +class ErrorDialog { + + /* Construct and show dialog */ + fun show( + context: Context, + errorTitle: Int, + errorMessage: Int, + errorDetails: String = String() + ) { + // prepare dialog builder + val builder = MaterialAlertDialogBuilder(context) + + // set title + builder.setTitle(context.getString(errorTitle)) + + // get views + val inflater: LayoutInflater = LayoutInflater.from(context) + val view: View = inflater.inflate(R.layout.dialog_generic_with_details, null) + val errorMessageView: TextView = view.findViewById(R.id.dialog_message) as TextView + val errorDetailsLinkView: TextView = view.findViewById(R.id.dialog_details_link) as TextView + val errorDetailsView: TextView = view.findViewById(R.id.dialog_details) as TextView + + // set dialog view + builder.setView(view) + + // set detail view + val detailsNotEmpty = errorDetails.isNotEmpty() + // show/hide details link depending on whether details are empty or not + errorDetailsLinkView.isVisible = detailsNotEmpty + + if (detailsNotEmpty) { + // allow scrolling on details view + errorDetailsView.movementMethod = ScrollingMovementMethod() + + // show and hide details on click + errorDetailsLinkView.setOnClickListener { + when (errorDetailsView.visibility) { + View.GONE -> errorDetailsView.isVisible = true + View.VISIBLE -> errorDetailsView.isGone = true + View.INVISIBLE -> { + return@setOnClickListener + } + } + } + // set details text view + errorDetailsView.text = errorDetails + } + + // set text views + errorMessageView.text = context.getString(errorMessage) + + // add okay button + builder.setPositiveButton(R.string.dialog_generic_button_okay) { _, _ -> + // listen for click on okay button + // do nothing + } + + // display error dialog + builder.show() + } +} diff --git a/app/src/main/java/com/michatec/radio/dialogs/FindStationDialog.kt b/app/src/main/java/com/michatec/radio/dialogs/FindStationDialog.kt new file mode 100644 index 0000000..88c62dd --- /dev/null +++ b/app/src/main/java/com/michatec/radio/dialogs/FindStationDialog.kt @@ -0,0 +1,280 @@ +/* + * FindStationDialog.kt + * Implements the FindStationDialog class + * A FindStationDialog shows a dialog with search box and list of results + * + * 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.dialogs + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.inputmethod.InputMethodManager +import android.widget.ProgressBar +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textview.MaterialTextView +import com.michatec.radio.Keys +import com.michatec.radio.R +import com.michatec.radio.core.Station +import com.michatec.radio.search.DirectInputCheck +import com.michatec.radio.search.RadioBrowserResult +import com.michatec.radio.search.RadioBrowserSearch +import com.michatec.radio.search.SearchResultAdapter + + +/* + * FindStationDialog class + */ +class FindStationDialog ( + private val context: Context, + private val listener: FindStationDialogListener): + SearchResultAdapter.SearchResultAdapterListener, + RadioBrowserSearch.RadioBrowserSearchListener, + DirectInputCheck.DirectInputCheckListener { + + /* Interface used to communicate back to activity */ + interface FindStationDialogListener { + fun onFindStationDialog(station: Station) { + } + } + + + /* Main class variables */ + private lateinit var dialog: AlertDialog + private lateinit var stationSearchBoxView: SearchView + private lateinit var searchRequestProgressIndicator: ProgressBar + private lateinit var noSearchResultsTextView: MaterialTextView + private lateinit var stationSearchResultList: RecyclerView + private lateinit var searchResultAdapter: SearchResultAdapter + private lateinit var radioBrowserSearch: RadioBrowserSearch + private lateinit var directInputCheck: DirectInputCheck + private var currentSearchString: String = String() + private val handler: Handler = Handler(Looper.getMainLooper()) + private var station: Station = Station() + + + /* Overrides onSearchResultTapped from SearchResultAdapterListener */ + override fun onSearchResultTapped(result: Station) { + station = result + // hide keyboard + val imm: InputMethodManager = + context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(stationSearchBoxView.windowToken, 0) + // make add button clickable + activateAddButton() + } + + + /* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */ + @SuppressLint("NotifyDataSetChanged") + override fun onRadioBrowserSearchResults(results: Array) { + if (results.isNotEmpty()) { + val stationList: List = results.map {it.toStation()} + searchResultAdapter.searchResults = stationList + searchResultAdapter.notifyDataSetChanged() + resetLayout(clearAdapter = false) + } else { + showNoResultsError() + } + } + + + /* Overrides onDirectInputCheck from DirectInputCheck */ + override fun onDirectInputCheck(stationList: MutableList) { + if (stationList.isNotEmpty()) { + val startPosition = searchResultAdapter.searchResults.size + searchResultAdapter.searchResults = stationList + searchResultAdapter.notifyItemRangeInserted(startPosition, stationList.size) + resetLayout(clearAdapter = false) + } else { + showNoResultsError() + } + } + + + /* Construct and show dialog */ + fun show() { + // initialize a radio browser search and direct url input check + radioBrowserSearch = RadioBrowserSearch(this) + directInputCheck = DirectInputCheck(this) + + // prepare dialog builder + val builder = MaterialAlertDialogBuilder(context) + + // set title + builder.setTitle(R.string.dialog_find_station_title) + + // get views + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.dialog_find_station, null) + stationSearchBoxView = view.findViewById(R.id.station_search_box_view) + searchRequestProgressIndicator = view.findViewById(R.id.search_request_progress_indicator) + stationSearchResultList = view.findViewById(R.id.station_search_result_list) + noSearchResultsTextView = view.findViewById(R.id.no_results_text_view) + noSearchResultsTextView.isGone = true + + // set up list of search results + setupRecyclerView(context) + + // add okay ("Add") button + builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ -> + // listen for click on add button + listener.onFindStationDialog(station) + searchResultAdapter.stopPrePlayback() + } + // add cancel button + builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ -> + // listen for click on cancel button + radioBrowserSearch.stopSearchRequest() + searchResultAdapter.stopPrePlayback() + } + // handle outside-click as "no" + builder.setOnCancelListener { + radioBrowserSearch.stopSearchRequest() + searchResultAdapter.stopPrePlayback() + } + + // listen for input + stationSearchBoxView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextChange(query: String): Boolean { + handleSearchBoxLiveInput(context, query) + searchResultAdapter.stopPrePlayback() + return true + } + + override fun onQueryTextSubmit(query: String): Boolean { + handleSearchBoxInput(context, query) + searchResultAdapter.stopPrePlayback() + return true + } + }) + + // set dialog view + builder.setView(view) + + // create and display dialog + dialog = builder.create() + dialog.show() + + // initially disable "Add" button + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isAllCaps = true + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).isAllCaps = true + } + + + /* Sets up list of results (RecyclerView) */ + private fun setupRecyclerView(context: Context) { + searchResultAdapter = SearchResultAdapter(this, listOf()) + stationSearchResultList.adapter = searchResultAdapter + val layoutManager: LinearLayoutManager = object : LinearLayoutManager(context) { + override fun supportsPredictiveItemAnimations(): Boolean { + return true + } + } + stationSearchResultList.layoutManager = layoutManager + stationSearchResultList.itemAnimator = DefaultItemAnimator() + } + + + /* Handles user input into search box - user has to submit the search */ + private fun handleSearchBoxInput(context: Context, query: String) { + when { + // handle empty search box input + query.isEmpty() -> { + resetLayout(clearAdapter = true) + } + // handle direct URL input + query.startsWith("http") -> { + directInputCheck.checkStationAddress(context, query) + } + // handle search string input + else -> { + showProgressIndicator() + radioBrowserSearch.searchStation(context, query, Keys.SEARCH_TYPE_BY_KEYWORD) + } + } + } + + + /* Handles live user input into search box */ + private fun handleSearchBoxLiveInput(context: Context, query: String) { + currentSearchString = query + if (query.startsWith("htt")) { + // handle direct URL input + directInputCheck.checkStationAddress(context, query) + } else if (query.contains(" ") || query.length > 2) { + // show progress indicator + showProgressIndicator() + // handle search string input - delay request to manage server load (not sure if necessary) + handler.postDelayed({ + // only start search if query is the same as one second ago + if (currentSearchString == query) radioBrowserSearch.searchStation( + context, + query, + Keys.SEARCH_TYPE_BY_KEYWORD + ) + }, 100) + } else if (query.isEmpty()) { + resetLayout(clearAdapter = true) + } + } + + + /* Makes the "Add" button clickable */ + override fun activateAddButton() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isGone = true + } + + override fun deactivateAddButton() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isGone = true + } + + + /* Resets the dialog layout to default state */ + private fun resetLayout(clearAdapter: Boolean = false) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isGone = true + searchResultAdapter.resetSelection(clearAdapter) + } + + + /* Display the "No Results" error - hide other unneeded views */ + private fun showNoResultsError() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isVisible = true + } + + + /* Display the "No Results" error - hide other unneeded views */ + private fun showProgressIndicator() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + searchRequestProgressIndicator.isVisible = true + noSearchResultsTextView.isGone = true + } + +} diff --git a/app/src/main/java/com/michatec/radio/dialogs/YesNoDialog.kt b/app/src/main/java/com/michatec/radio/dialogs/YesNoDialog.kt new file mode 100644 index 0000000..066728c --- /dev/null +++ b/app/src/main/java/com/michatec/radio/dialogs/YesNoDialog.kt @@ -0,0 +1,108 @@ +/* + * YesNoDialog + * Implements the YesNoDialog class + * A YesNoDialog asks the user if he/she wants to do something or not + * + * 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.dialogs + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.michatec.radio.Keys +import com.michatec.radio.R + + +/* + * YesNoDialog class + */ +class YesNoDialog(private var yesNoDialogListener: YesNoDialogListener) { + + /* Interface used to communicate back to activity */ + interface YesNoDialogListener { + fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) { + } + } + + + /* Main class variables */ + private lateinit var dialog: AlertDialog + + + /* Construct and show dialog - variant: message from string */ + fun show( + context: Context, + type: Int, + title: Int = Keys.EMPTY_STRING_RESOURCE, + message: Int, + yesButton: Int = R.string.dialog_yes_no_positive_button_default, + noButton: Int = R.string.dialog_generic_button_cancel, + payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT, + payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING + ) { + // extract string from message resource and feed into main show method + show( + context, + type, + title, + context.getString(message), + yesButton, + noButton, + payload, + payloadString + ) + } + + + /* Construct and show dialog */ + fun show( + context: Context, + type: Int, + title: Int = Keys.EMPTY_STRING_RESOURCE, + messageString: String, + yesButton: Int = R.string.dialog_yes_no_positive_button_default, + noButton: Int = R.string.dialog_generic_button_cancel, + payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT, + payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING + ) { + + // prepare dialog builder + val builder = MaterialAlertDialogBuilder(context) + + // set title and message + builder.setMessage(messageString) + if (title != Keys.EMPTY_STRING_RESOURCE) { + builder.setTitle(context.getString(title)) + } + + + // add yes button + builder.setPositiveButton(yesButton) { _, _ -> + // listen for click on yes button + yesNoDialogListener.onYesNoDialog(type, true, payload, payloadString) + } + + // add no button + builder.setNegativeButton(noButton) { _, _ -> + // listen for click on no button + yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString) + } + + // handle outside-click as "no" + builder.setOnCancelListener { + yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString) + } + + // display dialog + dialog = builder.create() + dialog.show() + } +} diff --git a/app/src/main/java/com/michatec/radio/extensions/ArrayListExt.kt b/app/src/main/java/com/michatec/radio/extensions/ArrayListExt.kt new file mode 100644 index 0000000..29a3958 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/extensions/ArrayListExt.kt @@ -0,0 +1,24 @@ +/* + * ArrayListExt.kt + * Implements the ArrayListExt extension methods + * Useful extension methods for ArrayLists + * Source: https://raw.githubusercontent.com/googlesamples/android-UniversalMusicPlayer/master/common/src/main/java/com/example/android/uamp/media/extensions/MediaMetadataCompatExt.kt + * + * 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.extensions + + +/* Creates a "real" copy of an ArrayList - useful for preventing concurrent modification issues */ +fun ArrayList.copy(): ArrayList { + val copy: ArrayList = ArrayList() + this.forEach { copy.add(it) } + return copy +} diff --git a/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt b/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt new file mode 100644 index 0000000..5235c1a --- /dev/null +++ b/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt @@ -0,0 +1,78 @@ +/* + * MediaControllerExt.kt + * Implements the MediaControllerExt extension methods + * Useful extension methods for MediaController + * + * 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.extensions + +import android.content.Context +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.media3.session.MediaController +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.ListenableFuture +import com.michatec.radio.Keys +import com.michatec.radio.core.Station +import com.michatec.radio.helpers.CollectionHelper + + +/* Starts the sleep timer */ +fun MediaController.startSleepTimer(timerDurationMillis: Long) { + val bundle = Bundle().apply { + putLong(Keys.SLEEP_TIMER_DURATION, timerDurationMillis) + } + sendCustomCommand(SessionCommand(Keys.CMD_START_SLEEP_TIMER, bundle), bundle) +} + + +/* Cancels the sleep timer */ +fun MediaController.cancelSleepTimer() { + sendCustomCommand(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY), Bundle.EMPTY) +} + + +/* Request sleep timer remaining */ +fun MediaController.requestSleepTimerRemaining(): ListenableFuture { + return sendCustomCommand( + SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY), + Bundle.EMPTY + ) +} + + +/* Request sleep timer remaining */ +fun MediaController.requestMetadataHistory(): ListenableFuture { + return sendCustomCommand( + SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY), + Bundle.EMPTY + ) +} + + +/* Starts playback with a new media item */ +fun MediaController.play(context: Context, station: Station) { + if (isPlaying) pause() + // set media item, prepare and play + setMediaItem(CollectionHelper.buildMediaItem(context, station)) + prepare() + play() +} + + +/* Starts playback with of a stream url */ +fun MediaController.playStreamDirectly(streamUri: String) { + sendCustomCommand( + SessionCommand(Keys.CMD_PLAY_STREAM, Bundle.EMPTY), + bundleOf(Pair(Keys.KEY_STREAM_URI, streamUri)) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/michatec/radio/helpers/AppThemeHelper.kt b/app/src/main/java/com/michatec/radio/helpers/AppThemeHelper.kt new file mode 100644 index 0000000..76e4c26 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/AppThemeHelper.kt @@ -0,0 +1,89 @@ +/* + * AppThemeHelper.kt + * Implements the AppThemeHelper object + * A AppThemeHelper can set the different app themes: Light Mode, Dark Mode, Follow System + * + * 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.res.TypedArray +import android.util.Log +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatDelegate +import com.michatec.radio.Keys +import com.michatec.radio.R + + +/* + * AppThemeHelper object + */ +object AppThemeHelper { + + /* Define log tag */ + private val TAG: String = AppThemeHelper::class.java.simpleName + + private val sTypedValue = TypedValue() + + /* Sets app theme */ + fun setTheme(nightModeState: String) { + when (nightModeState) { + Keys.STATE_THEME_DARK_MODE -> { + if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) { + // turn on dark mode + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + Log.i(TAG, "Dark Mode activated.") + } + } + Keys.STATE_THEME_LIGHT_MODE -> { + if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_NO) { + // turn on light mode + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + Log.i(TAG, "Theme: Light Mode activated.") + } + } + Keys.STATE_THEME_FOLLOW_SYSTEM -> { + if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) { + // turn on mode "follow system" + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + Log.i(TAG, "Theme: Follow System Mode activated.") + } + } + else -> { + // turn on mode "follow system" + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + Log.i(TAG, "Theme: Follow System Mode activated.") + } + } + } + + + /* Returns a readable String for currently selected App Theme */ + fun getCurrentTheme(context: Context): String { + return when (PreferencesHelper.loadThemeSelection()) { + Keys.STATE_THEME_LIGHT_MODE -> context.getString(R.string.pref_theme_selection_mode_light) + Keys.STATE_THEME_DARK_MODE -> context.getString(R.string.pref_theme_selection_mode_dark) + else -> context.getString(R.string.pref_theme_selection_mode_device_default) + } + } + + + @ColorInt + fun getColor(context: Context, @AttrRes resource: Int): Int { + val a: TypedArray = context.obtainStyledAttributes(sTypedValue.data, intArrayOf(resource)) + val color = a.getColor(0, 0) + a.recycle() + return color + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt b/app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt new file mode 100644 index 0000000..722173b --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt @@ -0,0 +1,64 @@ +/* + * AudioHelper.kt + * Implements the AudioHelper object + * A AudioHelper provides helper methods for handling audio files + * + * 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.util.Log +import androidx.media3.common.Metadata +import androidx.media3.extractor.metadata.icy.IcyHeaders +import androidx.media3.extractor.metadata.icy.IcyInfo +import com.michatec.radio.Keys +import kotlin.math.min + + +/* + * AudioHelper object + */ +object AudioHelper { + + + /* Define log tag */ + private val TAG: String = AudioHelper::class.java.simpleName + + + /* Extract audio stream metadata */ + fun getMetadataString(metadata: Metadata): String { + var metadataString = String() + for (i in 0 until metadata.length()) { + // extract IceCast metadata + when (val entry = metadata.get(i)) { + is IcyInfo -> { + metadataString = entry.title.toString() + } + + is IcyHeaders -> { + Log.i(TAG, "icyHeaders:" + entry.name + " - " + entry.genre) + } + + else -> { + Log.w(TAG, "Unsupported metadata received (type = ${entry.javaClass.simpleName})") + } + } + // TODO implement HLS metadata extraction (Id3Frame / PrivFrame) + // https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/metadata/Metadata.Entry.html + } + // ensure a max length of the metadata string + if (metadataString.isNotEmpty()) { + metadataString = metadataString.substring(0, min(metadataString.length, Keys.DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY)) + } + return metadataString + } + + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt b/app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt new file mode 100644 index 0000000..4a33aeb --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt @@ -0,0 +1,179 @@ +/* + * BackupHelper.kt + * Implements the BackupHelper object + * A BackupHelper provides helper methods for backing up and restoring the radio station collection + * + * 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.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import android.view.View +import com.google.android.material.snackbar.Snackbar +import com.michatec.radio.R +import java.io.* +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +object BackupHelper { + + + /* Define log tag */ + private val TAG: String = BackupHelper::class.java.simpleName + + + /* Compresses all files in the app's external files directory into destination zip file */ + fun backup(view: View, context: Context, destinationUri: Uri) { + val sourceFolder: File? = context.getExternalFilesDir("") + if (sourceFolder != null && sourceFolder.isDirectory) { + Snackbar.make( + view, + "${ + FileHelper.getFileName( + context, + destinationUri + ) + } ${context.getString(R.string.toastmessage_backed_up)}", + Snackbar.LENGTH_LONG + ).show() + val resolver: ContentResolver = context.contentResolver + val outputStream: OutputStream? = resolver.openOutputStream(destinationUri) + ZipOutputStream(BufferedOutputStream(outputStream)).use { zipOutputStream -> + zipOutputStream.use { + zipFolder(it, sourceFolder, "") + } + } + } else { + Log.e(TAG, "Unable to access External Storage.") + } + } + + + /* Extracts zip backup file and restores files and folders - Credit: https://www.baeldung.com/java-compress-and-uncompress*/ + fun restore(view: View, context: Context, sourceUri: Uri) { + Snackbar.make(view, R.string.toastmessage_restored, Snackbar.LENGTH_LONG).show() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // bypass "ZipException" for Android 14 or above applications when zip file names contain ".." or start with "/" + dalvik.system.ZipPathValidator.clearCallback() + } + + val resolver: ContentResolver = context.contentResolver + val sourceInputStream: InputStream? = resolver.openInputStream(sourceUri) + val destinationFolder: File? = context.getExternalFilesDir("") + val buffer = ByteArray(1024) + val zipInputStream = ZipInputStream(sourceInputStream) + var zipEntry: ZipEntry? = zipInputStream.nextEntry + + // iterate through ZipInputStream until last ZipEntry + while (zipEntry != null) { + try { + val newFile: File = getFile(destinationFolder!!, zipEntry) + when (zipEntry.isDirectory) { + // CASE: Folder + true -> { + // create folder if zip entry is a folder + if (!newFile.isDirectory && !newFile.mkdirs()) { + Log.w(TAG, "Failed to create directory $newFile") + } + } + // CASE: File + false -> { + // create parent directory, if necessary + val parent: File? = newFile.parentFile + if (parent != null && !parent.isDirectory && !parent.mkdirs()) { + Log.w(TAG, "Failed to create directory $parent") + } + // write file content + val fileOutputStream = FileOutputStream(newFile) + var len: Int + while (zipInputStream.read(buffer).also { len = it } > 0) { + fileOutputStream.write(buffer, 0, len) + } + fileOutputStream.close() + } + } + } catch (e: Exception) { + Log.e(TAG, "Unable to safely create file. $e") + } + // get next entry - zipEntry will be null, when zipInputStream has no more entries left + zipEntry = zipInputStream.nextEntry + } + zipInputStream.closeEntry() + zipInputStream.close() + + // notify CollectionViewModel that collection has changed + CollectionHelper.sendCollectionBroadcast( + context, + modificationDate = Calendar.getInstance().time + ) + } + + + /* Compresses folder into ZIP file - Credit: https://stackoverflow.com/a/52216574 */ + private fun zipFolder(zipOutputStream: ZipOutputStream, source: File, parentDirPath: String) { + // source.listFiles() will return null, if source is not a directory + if (source.isDirectory) { + val data = ByteArray(2048) + // get all File objects in folder + for (file in source.listFiles()!!) { + // make sure that path does not start with a separator (/) + val path: String = if (parentDirPath.isEmpty()) file.name else parentDirPath + File.separator + file.name + when (file.isDirectory) { + // CASE: Folder + true -> { + // call zipFolder recursively to add files within this folder + zipFolder(zipOutputStream, file, path) + } + // CASE: File + false -> { + FileInputStream(file).use { fileInputStream -> + BufferedInputStream(fileInputStream).use { bufferedInputStream -> + val entry = ZipEntry(path) + entry.time = file.lastModified() + entry.size = file.length() + zipOutputStream.putNextEntry(entry) + while (true) { + val readBytes = bufferedInputStream.read(data) + if (readBytes == -1) { + break + } + zipOutputStream.write(data, 0, readBytes) + } + } + } + } + } + } + } + } + + + /* Normalize file path - protects against zip slip attack */ + @Throws(IOException::class) + private fun getFile(destinationFolder: File, zipEntry: ZipEntry): File { + val destinationFile = File(destinationFolder, zipEntry.name) + val destinationFolderPath = destinationFolder.canonicalPath + val destinationFilePath = destinationFile.canonicalPath + // make sure that zipEntry path is in the destination folder + if (!destinationFilePath.startsWith(destinationFolderPath + File.separator)) { + throw IOException("ZIP entry is not within of the destination folder: " + zipEntry.name) + } + return destinationFile + } + + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt b/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt new file mode 100644 index 0000000..ab181f9 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt @@ -0,0 +1,773 @@ +/* + * 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() + } + } + + + /* Gets MediaIem for next station within collection */ + fun getNextMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem { + val currentStationPosition: Int = getStationPosition(collection, stationUuid) + return if (collection.stations.isEmpty() || currentStationPosition == -1) { + buildMediaItem(context, Station()) + } else if (currentStationPosition < collection.stations.size -1) { + buildMediaItem(context, collection.stations[currentStationPosition + 1]) + } else { + buildMediaItem(context, collection.stations.first()) + } + } + + + /* Gets MediaIem for previous station within collection */ + fun getPreviousMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem { + val currentStationPosition: Int = getStationPosition(collection, stationUuid) + return if (collection.stations.isEmpty() || currentStationPosition == -1) { + buildMediaItem(context, Station()) + } else if (currentStationPosition > 0) { + buildMediaItem(context, collection.stations[currentStationPosition - 1]) + } else { + buildMediaItem(context, collection.stations.last()) + } + } + + + /* 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.size > 0) { + 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.size > 0) { + 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 + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt b/app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt new file mode 100644 index 0000000..ba1ef48 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt @@ -0,0 +1,103 @@ +/* + * DateTimeHelper.kt + * Implements the DateTimeHelper object + * A DateTimeHelper provides helper methods for converting Date and Time objects + * + * 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.util.Log +import com.michatec.radio.Keys +import java.text.SimpleDateFormat +import java.util.* + + +/* + * DateTimeHelper object + */ +object DateTimeHelper { + + + /* Define log tag */ + private val TAG: String = DateTimeHelper::class.java.simpleName + + + /* Main class variables */ + private const val pattern: String = "EEE, dd MMM yyyy HH:mm:ss Z" + private val dateFormat: SimpleDateFormat = SimpleDateFormat(pattern, Locale.ENGLISH) + + + /* Converts RFC 2822 string representation of a date to DATE */ + fun convertFromRfc2822(dateString: String): Date { + val date: Date = try { + // parse date string using standard pattern + dateFormat.parse((dateString)) ?: Keys.DEFAULT_DATE + } catch (e: Exception) { + Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e") + // try alternative parsing patterns + tryAlternativeRfc2822Parsing(dateString) + } + return date + } + + + /* Converts a DATE to its RFC 2822 string representation */ + fun convertToRfc2822(date: Date): String { + val dateFormat = SimpleDateFormat(pattern, Locale.ENGLISH) + return dateFormat.format(date) + } + + + /* Converts a milliseconds into a readable format (HH:mm:ss) */ + fun convertToHoursMinutesSeconds(milliseconds: Long, negativeValue: Boolean = false): String { + // convert milliseconds to hours, minutes, and seconds + val hours: Long = milliseconds / 1000 / 3600 + val minutes: Long = milliseconds / 1000 % 3600 / 60 + val seconds: Long = milliseconds / 1000 % 60 + val hourPart = if (hours > 0) { + "${hours.toString().padStart(2, '0')}:" + } else { + "" + } + + var timeString = + "$hourPart${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + if (negativeValue) { + // add a minus sign if a negative values was requested + timeString = "-$timeString" + } + return timeString + } + + + /* Converts RFC 2822 string representation of a date to DATE - using alternative patterns */ + private fun tryAlternativeRfc2822Parsing(dateString: String): Date { + var date: Date = Keys.DEFAULT_DATE + try { + // try to parse without seconds + date = SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.ENGLISH).parse((dateString)) + ?: Keys.DEFAULT_DATE + } catch (e: Exception) { + try { + Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e") + // try to parse without time zone + date = SimpleDateFormat( + "EEE, dd MMM yyyy HH:mm:ss", + Locale.ENGLISH + ).parse((dateString)) ?: Keys.DEFAULT_DATE + } catch (e: Exception) { + Log.e(TAG, "Unable to parse. Returning a default date. $e") + } + } + return date + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/DownloadFinishedReceiver.kt b/app/src/main/java/com/michatec/radio/helpers/DownloadFinishedReceiver.kt new file mode 100644 index 0000000..8a232dd --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/DownloadFinishedReceiver.kt @@ -0,0 +1,36 @@ +/* + * DownloadFinishedReceiver.kt + * Implements the DownloadFinishedReceiver class + * A DownloadFinishedReceiver listens for finished downloads + * + * 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.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + + +/* + * DownloadFinishedReceiver class + */ +class DownloadFinishedReceiver : BroadcastReceiver() { + + /* Overrides onReceive */ + override fun onReceive(context: Context, intent: Intent) { + // process the finished download + DownloadHelper.processDownload( + context, + intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L) + ) + } +} diff --git a/app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt b/app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt new file mode 100644 index 0000000..b5f38b1 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt @@ -0,0 +1,396 @@ +/* + * DownloadHelper.kt + * Implements the DownloadHelper object + * A DownloadHelper provides helper methods for downloading files + * + * 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.app.DownloadManager +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.core.net.toUri +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.extensions.copy +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import java.util.* + + +/* + * DownloadHelper object + */ +object DownloadHelper { + + + /* Define log tag */ + private val TAG: String = DownloadHelper::class.java.simpleName + + + /* Main class variables */ + private lateinit var collection: Collection + private lateinit var downloadManager: DownloadManager + private lateinit var activeDownloads: ArrayList + private lateinit var modificationDate: Date + + + /* Download station playlists */ + fun downloadPlaylists(context: Context, playlistUrlStrings: Array) { + // initialize main class variables, if necessary + initialize(context) + // convert array + val uris: Array = + Array(playlistUrlStrings.size) { index -> playlistUrlStrings[index].toUri() } + // enqueue playlists + enqueueDownload(context, uris, Keys.FILE_TYPE_PLAYLIST) + } + + + /* Refresh image of given station */ + fun updateStationImage(context: Context, station: Station) { + // initialize main class variables, if necessary + initialize(context) + // check if station has an image reference + if (station.remoteImageLocation.isNotEmpty()) { + CollectionHelper.clearImagesFolder(context, station) + val uris: Array = Array(1) { station.remoteImageLocation.toUri() } + enqueueDownload(context, uris, Keys.FILE_TYPE_IMAGE) + } + } + + + /* Updates all station images */ + fun updateStationImages(context: Context) { + // initialize main class variables, if necessary + initialize(context) + // re-download all station images + PreferencesHelper.saveLastUpdateCollection() + val uris: MutableList = mutableListOf() + collection.stations.forEach { station -> + station.radioBrowserStationUuid + if (!station.imageManuallySet) { + uris.add(station.remoteImageLocation.toUri()) + } + } + enqueueDownload(context, uris.toTypedArray(), Keys.FILE_TYPE_IMAGE) + Log.i(TAG, "Updating all station images.") + } + + + /* Processes a given download ID */ + fun processDownload(context: Context, downloadId: Long) { + // initialize main class variables, if necessary + initialize(context) + // get local Uri in content://downloads/all_downloads/ for download ID + val downloadResult: Uri? = downloadManager.getUriForDownloadedFile(downloadId) + if (downloadResult == null) { + val downloadErrorCode: Int = getDownloadError(downloadId) + val downloadErrorFileName: String = getDownloadFileName(downloadManager, downloadId) + Toast.makeText( + context, + "${context.getString(R.string.toastmessage_error_download_error)}: $downloadErrorFileName ($downloadErrorCode)", + Toast.LENGTH_LONG + ).show() + Log.w( + TAG, + "Download not successful: File name = $downloadErrorFileName Error code = $downloadErrorCode" + ) + removeFromActiveDownloads(arrayOf(downloadId), deleteDownload = true) + return + } else { + val localFileUri: Uri = downloadResult + // get remote URL for download ID + val remoteFileLocation: String = getRemoteFileLocation(downloadManager, downloadId) + // determine what to do + val fileType = FileHelper.getContentType(context, localFileUri) + if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && CollectionHelper.isNewStation( + collection, + remoteFileLocation + ) + ) { + addStation(context, localFileUri, remoteFileLocation) + } else if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && !CollectionHelper.isNewStation( + collection, + remoteFileLocation + ) + ) { + updateStation(context, localFileUri, remoteFileLocation) + } else if (fileType in Keys.MIME_TYPES_IMAGE) { + collection = CollectionHelper.setStationImageWithRemoteLocation( + context, + collection, + localFileUri.toString(), + remoteFileLocation, + false + ) + } else if (fileType in Keys.MIME_TYPES_FAVICON) { + collection = CollectionHelper.setStationImageWithRemoteLocation( + context, + collection, + localFileUri.toString(), + remoteFileLocation, + false + ) + } + // remove ID from active downloads + removeFromActiveDownloads(arrayOf(downloadId)) + } + } + + + /* Initializes main class variables of DownloadHelper, if necessary */ + private fun initialize(context: Context) { + if (!this::modificationDate.isInitialized) { + modificationDate = PreferencesHelper.loadCollectionModificationDate() + } + if (!this::collection.isInitialized || CollectionHelper.isNewerCollectionAvailable( + modificationDate + ) + ) { + collection = FileHelper.readCollection(context) + modificationDate = PreferencesHelper.loadCollectionModificationDate() + } + if (!this::downloadManager.isInitialized) { + FileHelper.clearFolder(context.getExternalFilesDir(Keys.FOLDER_TEMP), 0) + downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + } + if (!this::activeDownloads.isInitialized) { + activeDownloads = getActiveDownloads() + } + } + + + /* Enqueues an Array of files in DownloadManager */ + private fun enqueueDownload( + context: Context, + uris: Array, + type: Int, + ignoreWifiRestriction: Boolean = false + ) { + // determine allowed network types + val allowedNetworkTypes: Int = determineAllowedNetworkTypes(type, ignoreWifiRestriction) + // enqueue downloads + val newIds = LongArray(uris.size) + for (i in uris.indices) { + Log.v(TAG, "DownloadManager enqueue: ${uris[i]}") + // check if valid url and prevent double download + val uri: Uri = uris[i] + val scheme: String = uri.scheme ?: String() + val pathSegments: List = uri.pathSegments + if (scheme.startsWith("http") && isNotInDownloadQueue(uri.toString()) && pathSegments.isNotEmpty()) { + val fileName: String = pathSegments.last() + val request: DownloadManager.Request = DownloadManager.Request(uri) + .setAllowedNetworkTypes(allowedNetworkTypes) + .setTitle(fileName) + .setDestinationInExternalFilesDir(context, Keys.FOLDER_TEMP, fileName) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + newIds[i] = downloadManager.enqueue(request) + activeDownloads.add(newIds[i]) + } + } + setActiveDownloads(activeDownloads) + } + + + /* Checks if a file is not yet in download queue */ + private fun isNotInDownloadQueue(remoteFileLocation: String): Boolean { + val activeDownloadsCopy = activeDownloads.copy() + activeDownloadsCopy.forEach { downloadId -> + if (getRemoteFileLocation(downloadManager, downloadId) == remoteFileLocation) { + Log.w(TAG, "File is already in download queue: $remoteFileLocation") + return false + } + } + Log.v(TAG, "File is not in download queue.") + return true + } + + + /* Safely remove given download IDs from activeDownloads and delete download if requested */ + private fun removeFromActiveDownloads( + downloadIds: Array, + deleteDownload: Boolean = false + ): Boolean { + // remove download ids from activeDownloads + val success: Boolean = + activeDownloads.removeAll { downloadId -> downloadIds.contains(downloadId) } + if (success) { + setActiveDownloads(activeDownloads) + } + // optionally: delete download + if (deleteDownload) { + downloadIds.forEach { downloadId -> downloadManager.remove(downloadId) } + } + return success + } + + + /* Reads station playlist file and adds it to collection */ + private fun addStation(context: Context, localFileUri: Uri, remoteFileLocation: String) { + // read station playlist + val station: Station = CollectionHelper.createStationFromPlaylistFile( + context, + localFileUri, + remoteFileLocation + ) + // detect content type on background thread + CoroutineScope(IO).launch { + val deferred: Deferred = + async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) } + // wait for result + val contentType: NetworkHelper.ContentType = deferred.await() + // set content type + station.streamContent = contentType.type + // add station and save collection + withContext(Main) { + collection = CollectionHelper.addStation(context, collection, station) + } + } + } + + + /* Reads station playlist file and updates it in collection */ + private fun updateStation(context: Context, localFileUri: Uri, remoteFileLocation: String) { + // read station playlist + val station: Station = CollectionHelper.createStationFromPlaylistFile( + context, + localFileUri, + remoteFileLocation + ) + // detect content type on background thread + CoroutineScope(IO).launch { + val deferred: Deferred = + async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) } + // wait for result + val contentType: NetworkHelper.ContentType = deferred.await() + // update content type + station.streamContent = contentType.type + // update station and save collection + withContext(Main) { + collection = CollectionHelper.updateStation(context, collection, station) + } + } + } + + + /* Saves active downloads (IntArray) to shared preferences */ + private fun setActiveDownloads(activeDownloads: ArrayList) { + val builder = StringBuilder() + for (i in activeDownloads.indices) { + builder.append(activeDownloads[i]).append(",") + } + var activeDownloadsString: String = builder.toString() + if (activeDownloadsString.isEmpty()) { + activeDownloadsString = Keys.ACTIVE_DOWNLOADS_EMPTY + } + PreferencesHelper.saveActiveDownloads(activeDownloadsString) + } + + + /* Loads active downloads (IntArray) from shared preferences */ + private fun getActiveDownloads(): ArrayList { + var inactiveDownloadsFound = false + val activeDownloadsList: ArrayList = arrayListOf() + val activeDownloadsString: String = PreferencesHelper.loadActiveDownloads() + val count = activeDownloadsString.split(",").size - 1 + val tokenizer = StringTokenizer(activeDownloadsString, ",") + repeat(count) { + val token = tokenizer.nextToken().toLong() + when (isDownloadActive(token)) { + true -> activeDownloadsList.add(token) + false -> inactiveDownloadsFound = true + } + } + if (inactiveDownloadsFound) setActiveDownloads(activeDownloadsList) + return activeDownloadsList + } + + + /* Determines the remote file location (the original URL) */ + private fun getRemoteFileLocation(downloadManager: DownloadManager, downloadId: Long): String { + var remoteFileLocation = "" + val cursor: Cursor = + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.count > 0) { + cursor.moveToFirst() + remoteFileLocation = + cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)) + } + return remoteFileLocation + } + + + /* Determines the file name for given download id (the original URL) */ + private fun getDownloadFileName(downloadManager: DownloadManager, downloadId: Long): String { + var remoteFileLocation = "" + val cursor: Cursor = + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.count > 0) { + cursor.moveToFirst() + remoteFileLocation = + cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE)) + } + return remoteFileLocation + } + + + /* Checks if a given download ID represents a finished download */ + private fun isDownloadActive(downloadId: Long): Boolean { + var downloadStatus: Int = -1 + val cursor: Cursor = + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.count > 0) { + cursor.moveToFirst() + downloadStatus = + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + } + return downloadStatus == DownloadManager.STATUS_RUNNING + } + + + /* Retrieves reason of download error - returns http error codes plus error codes found here check: https://developer.android.com/reference/android/app/DownloadManager */ + private fun getDownloadError(downloadId: Long): Int { + var reason: Int = -1 + val cursor: Cursor = + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) + if (cursor.count > 0) { + cursor.moveToFirst() + val downloadStatus = + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + if (downloadStatus == DownloadManager.STATUS_FAILED) { + reason = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)) + } + } + return reason + } + + + /* Determine allowed network type */ + private fun determineAllowedNetworkTypes(type: Int, ignoreWifiRestriction: Boolean): Int { + var allowedNetworkTypes: Int = + (DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) + // restrict download of audio files to WiFi if necessary + if (type == Keys.FILE_TYPE_AUDIO) { + if (!ignoreWifiRestriction && !PreferencesHelper.downloadOverMobile()) { + allowedNetworkTypes = DownloadManager.Request.NETWORK_WIFI + } + } + return allowedNetworkTypes + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/FileHelper.kt b/app/src/main/java/com/michatec/radio/helpers/FileHelper.kt new file mode 100644 index 0000000..979617d --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/FileHelper.kt @@ -0,0 +1,497 @@ +/* + * FileHelper.kt + * Implements the FileHelper object + * A FileHelper provides helper methods for reading and writing files from and to device storage + * + * 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.app.Activity +import android.content.Context +import android.database.Cursor +import android.graphics.Bitmap +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.michatec.radio.Keys +import com.michatec.radio.core.Collection +import com.michatec.radio.core.Station +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import java.io.* +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + + +/* + * FileHelper object + */ +object FileHelper { + + + /* Define log tag */ + private val TAG: String = FileHelper::class.java.simpleName + + + /* Get file size for given Uri */ + fun getFileSize(context: Context, uri: Uri): Long { + val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) + return if (cursor != null) { + val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + val size: Long = cursor.getLong(sizeIndex) + cursor.close() + size + } else { + 0L + } + } + + + /* Get file name for given Uri */ + fun getFileName(context: Context, uri: Uri): String { + val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) + return if (cursor != null) { + val nameIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + val name: String = cursor.getString(nameIndex) + cursor.close() + name + } else { + String() + } + } + + + /* Get content type for given file */ + fun getContentType(context: Context, uri: Uri): String { + // get file type from content resolver + var contentType: String = context.contentResolver.getType(uri) ?: Keys.MIME_TYPE_UNSUPPORTED + contentType = contentType.lowercase(Locale.getDefault()) + return if (contentType != Keys.MIME_TYPE_UNSUPPORTED && !contentType.contains(Keys.MIME_TYPE_OCTET_STREAM)) { + // return the found content type + contentType + } else { + // fallback: try to determine file type based on file extension + getContentTypeFromExtension(getFileName(context, uri)) + } + } + + + /* Determine content type based on file extension */ + fun getContentTypeFromExtension(fileName: String): String { + Log.i(TAG, "Deducing content type from file name: $fileName") + if (fileName.endsWith("m3u", true)) return Keys.MIME_TYPE_M3U + if (fileName.endsWith("pls", true)) return Keys.MIME_TYPE_PLS + if (fileName.endsWith("png", true)) return Keys.MIME_TYPE_PNG + if (fileName.endsWith("jpg", true)) return Keys.MIME_TYPE_JPG + if (fileName.endsWith("jpeg", true)) return Keys.MIME_TYPE_JPG + // default return + return Keys.MIME_TYPE_UNSUPPORTED + } + + + /* Determines a destination folder */ + fun determineDestinationFolderPath(type: Int, stationUuid: String): String { + val folderPath: String = when (type) { + Keys.FILE_TYPE_PLAYLIST -> Keys.FOLDER_TEMP + Keys.FILE_TYPE_AUDIO -> Keys.FOLDER_AUDIO + "/" + stationUuid + Keys.FILE_TYPE_IMAGE -> Keys.FOLDER_IMAGES + "/" + stationUuid + else -> "/" + } + return folderPath + } + + + /* Clears given folder - keeps given number of files */ + fun clearFolder(folder: File?, keep: Int, deleteFolder: Boolean = false) { + if (folder != null && folder.exists()) { + val files = folder.listFiles()!! + val fileCount: Int = files.size + files.sortBy { it.lastModified() } + for (fileNumber in files.indices) { + if (fileNumber < fileCount - keep) { + files[fileNumber].delete() + } + } + if (deleteFolder && keep == 0) { + folder.delete() + } + } + } + + + /* Creates and save a scaled version of the station image */ + fun saveStationImage( + context: Context, + stationUuid: String, + sourceImageUri: Uri, + size: Int, + fileName: String + ): Uri { + val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, sourceImageUri, size) + val file = File( + context.getExternalFilesDir( + determineDestinationFolderPath( + Keys.FILE_TYPE_IMAGE, + stationUuid + ) + ), fileName + ) + writeImageFile(coverBitmap, file) + return file.toUri() + } + + + /* Saves collection of radio stations as JSON text file */ + fun saveCollection(context: Context, collection: Collection, lastSave: Date) { + Log.v(TAG, "Saving collection - Thread: ${Thread.currentThread().name}") + val collectionSize: Int = collection.stations.size + // do not override an existing collection with an empty one - except when last station is deleted + if (collectionSize > 0 || PreferencesHelper.loadCollectionSize() == 1) { + // convert to JSON + val gson: Gson = getCustomGson() + var json = String() + try { + json = gson.toJson(collection) + } catch (e: Exception) { + e.printStackTrace() + } + if (json.isNotBlank()) { + // write text file + writeTextFile(context, json, Keys.FOLDER_COLLECTION, Keys.COLLECTION_FILE) + // save modification date and collection size + PreferencesHelper.saveCollectionModificationDate(lastSave) + PreferencesHelper.saveCollectionSize(collectionSize) + } else { + Log.w(TAG, "Not writing collection file. Reason: JSON string was completely empty.") + } + } else { + Log.w( + TAG, + "Not saving collection. Reason: Trying to override an collection with more than one station" + ) + } + } + + + /* Reads m3u or pls playlists */ + fun readStationPlaylist(playlistInputStream: InputStream?): Station { + val station = Station() + if (playlistInputStream != null) { + val reader = BufferedReader(InputStreamReader(playlistInputStream)) + // until last line reached: read station name and stream address(es) + reader.forEachLine { line -> + when { + // M3U: found station name + line.contains("#EXTINF:-1,") -> station.name = line.substring(11).trim() + line.contains("#EXTINF:0,") -> station.name = line.substring(10).trim() + // M3U: found stream URL + line.startsWith("http") -> station.streamUris.add(0, line.trim()) + // PLS: found station name + line.matches(Regex("^Title[0-9]+=.*")) -> station.name = + line.substring(line.indexOf("=") + 1).trim() + // PLS: found stream URL + line.matches(Regex("^File[0-9]+=http.*")) -> station.streamUris.add( + line.substring( + line.indexOf("=") + 1 + ).trim() + ) + } + + } + playlistInputStream.close() + } + return station + } + + + /* Reads collection of radio stations from storage using GSON */ + fun readCollection(context: Context): Collection { + Log.v(TAG, "Reading collection - Thread: ${Thread.currentThread().name}") + // get JSON from text file + val json: String = readTextFileFromFile(context) + var collection = Collection() + if (json.isNotBlank()) { + // convert JSON and return as collection + try { + collection = getCustomGson().fromJson(json, collection::class.java) + } catch (e: Exception) { + Log.e(TAG, "Error Reading collection.\nContent: $json") + e.printStackTrace() + } + } + return collection + } + + + /* Get content Uri for M3U file */ + fun getM3ulUri(activity: Activity): Uri? { + var m3ulUri: Uri? = null + // try to get an existing M3U File + var m3uFile = + File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_M3U_FILE) + if (!m3uFile.exists()) { + m3uFile = File( + activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION), + Keys.COLLECTION_M3U_FILE + ) + } + // get Uri for existing M3U File + if (m3uFile.exists()) { + m3ulUri = FileProvider.getUriForFile( + activity, + "${activity.applicationContext.packageName}.provider", + m3uFile + ) + } + return m3ulUri + } + + + /* Get content Uri for PLS file */ + fun getPlslUri(activity: Activity): Uri? { + var plslUri: Uri? = null + // try to get an existing PLS File + var plsFile = + File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_PLS_FILE) + if (!plsFile.exists()) { + plsFile = File( + activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION), + Keys.COLLECTION_PLS_FILE + ) + } + // get Uri for existing M3U File + if (plsFile.exists()) { + plslUri = FileProvider.getUriForFile( + activity, + "${activity.applicationContext.packageName}.provider", + plsFile + ) + } + return plslUri + } + + + /* Suspend function: Wrapper for saveCollection */ + suspend fun saveCollectionSuspended( + context: Context, + collection: Collection, + lastUpdate: Date + ) { + return suspendCoroutine { cont -> + cont.resume(saveCollection(context, collection, lastUpdate)) + } + } + + + /* Suspend function: Wrapper for readCollection */ + suspend fun readCollectionSuspended(context: Context): Collection = + withContext(IO) { + readCollection(context) + } + + + /* Suspend function: Wrapper for copyFile */ + suspend fun saveCopyOfFileSuspended( + context: Context, + originalFileUri: Uri, + targetFileUri: Uri + ): Boolean { + return suspendCoroutine { cont -> + cont.resume(copyFile(context, originalFileUri, targetFileUri)) + } + } + + + /* Suspend function: Exports collection of stations as M3U file - local backup copy */ + suspend fun backupCollectionAsM3uSuspended(context: Context, collection: Collection) { + return suspendCoroutine { cont -> + Log.v(TAG, "Backing up collection as M3U - Thread: ${Thread.currentThread().name}") + // create M3U string + val m3uString: String = CollectionHelper.createM3uString(collection) + // save M3U as text file + cont.resume( + writeTextFile( + context, + m3uString, + Keys.FOLDER_COLLECTION, + Keys.COLLECTION_M3U_FILE + ) + ) + } + } + + + /* Suspend function: Exports collection of stations as PLS file - local backup copy */ + suspend fun backupCollectionAsPlsSuspended(context: Context, collection: Collection) { + return suspendCoroutine { cont -> + Log.v(TAG, "Backing up collection as PLS - Thread: ${Thread.currentThread().name}") + // create PLS string + val plsString: String = CollectionHelper.createPlsString(collection) + // save PLS as text file + cont.resume( + writeTextFile( + context, + plsString, + Keys.FOLDER_COLLECTION, + Keys.COLLECTION_PLS_FILE + ) + ) + } + } + + + /* Copies file to specified target */ + private fun copyFile( + context: Context, + originalFileUri: Uri, + targetFileUri: Uri, + ): Boolean { + var success = true + var inputStream: InputStream? = null + val outputStream: OutputStream? + try { + inputStream = context.contentResolver.openInputStream(originalFileUri) + outputStream = context.contentResolver.openOutputStream(targetFileUri) + if (outputStream != null && inputStream != null) { + inputStream.copyTo(outputStream) + outputStream.close() // Close the output stream after copying + } + } catch (exception: Exception) { + Log.e(TAG, "Unable to copy file.") + success = false + exception.printStackTrace() + } finally { + inputStream?.close() // Close the input stream in the finally block + } + if (success) { + try { + // use contentResolver to handle files of type content:// + context.contentResolver.delete(originalFileUri, null, null) + } catch (e: Exception) { + Log.e(TAG, "Unable to delete the original file. Stack trace: $e") + } + } + return success + } + + + /* Creates a Gson object */ + private fun getCustomGson(): Gson { + val gsonBuilder = GsonBuilder() + gsonBuilder.setDateFormat("M/d/yy hh:mm a") + gsonBuilder.excludeFieldsWithoutExposeAnnotation() + return gsonBuilder.create() + } + + + /* Create nomedia file in given folder to prevent media scanning */ + fun createNomediaFile(folder: File?) { + if (folder != null && folder.exists() && folder.isDirectory) { + val nomediaFile: File = getNoMediaFile(folder) + if (!nomediaFile.exists()) { + val noMediaOutStream = FileOutputStream(getNoMediaFile(folder)) + noMediaOutStream.write(0) + } else { + Log.v(TAG, ".nomedia file exists already in given folder.") + } + } else { + Log.w(TAG, "Unable to create .nomedia file. Given folder is not valid.") + } + } + + + /* Reads InputStream from file uri and returns it as String */ + private fun readTextFileFromFile(context: Context): String { + // todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html + // https://developer.android.com/training/secure-file-sharing/retrieve-info + + // check if file exists + val file = File(context.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_FILE) + if (!file.exists() || !file.canRead()) { + return String() + } + // read until last line reached + val stream: InputStream = file.inputStream() + val reader = BufferedReader(InputStreamReader(stream)) + val builder: StringBuilder = StringBuilder() + reader.forEachLine { + builder.append(it) + builder.append("\n") + } + stream.close() + return builder.toString() + } + + + /* Reads InputStream from content uri and returns it as List of String */ + fun readTextFileFromContentUri(context: Context, contentUri: Uri): List { + val lines: MutableList = mutableListOf() + try { + // open input stream from content URI + val inputStream: InputStream? = context.contentResolver.openInputStream(contentUri) + if (inputStream != null) { + val reader: InputStreamReader = inputStream.reader() + var index = 0 + reader.forEachLine { + index += 1 + if (index < 256) + lines.add(it) + } + inputStream.close() + } + } catch (e: Exception) { + e.printStackTrace() + } + return lines + } + + + /* Writes given text to file on storage */ + @Suppress("SameParameterValue") + private fun writeTextFile(context: Context, text: String, folder: String, fileName: String) { + if (text.isNotBlank()) { + File(context.getExternalFilesDir(folder), fileName).writeText(text) + } else { + Log.w(TAG, "Writing text file $fileName failed. Empty text string text was provided.") + } + } + + + /* Writes given bitmap as image file to storage */ + private fun writeImageFile( + bitmap: Bitmap, + file: File + ) { + if (file.exists()) file.delete() + try { + val out = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.JPEG, 75, out) + out.flush() + out.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + + /* Returns a nomedia file object */ + private fun getNoMediaFile(folder: File): File { + return File(folder, ".nomedia") + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt b/app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt new file mode 100644 index 0000000..1f16ab1 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt @@ -0,0 +1,257 @@ +/* + * ImageHelper.kt + * Implements the ImageHelper object + * An ImageHelper provides helper methods for image related operations + * + * 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.graphics.* +import android.net.Uri +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.palette.graphics.Palette +import com.michatec.radio.R +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + + +/* + * ImageHelper class + */ +object ImageHelper { + + /* Get scaling factor from display density */ + fun getDensityScalingFactor(context: Context): Float { + return context.resources.displayMetrics.density + } + + + /* Get a scaled version of the station image */ + fun getScaledStationImage(context: Context, imageUri: Uri, imageSize: Int): Bitmap { + val size: Int = (imageSize * getDensityScalingFactor(context)).toInt() + return decodeSampledBitmapFromUri(context, imageUri, size, size) + } + + + /* Get an unscaled version of the station image */ + fun getStationImage(context: Context, imageUriString: String): Bitmap { + var bitmap: Bitmap? = null + + if (imageUriString.isNotEmpty()) { + try { + // just decode the file + bitmap = BitmapFactory.decodeFile(imageUriString.toUri().path) + } catch (e: Exception) { + e.printStackTrace() + } + } + + // get default image + if (bitmap == null) { + bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!! + .toBitmap() + } + + return bitmap + } + + + /* Get an unscaled version of the station image as a ByteArray */ + fun getStationImageAsByteArray(context: Context, imageUriString: String = String()): ByteArray { + val coverBitmap: Bitmap = getStationImage(context, imageUriString) + val stream = ByteArrayOutputStream() + coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val coverByteArray: ByteArray = stream.toByteArray() + coverBitmap.recycle() + return coverByteArray + } + + + /* Creates station image on a square background with the main station image color and option padding for adaptive icons */ + fun createSquareImage( + context: Context, + bitmap: Bitmap, + backgroundColor: Int, + size: Int, + adaptivePadding: Boolean + ): Bitmap { + + // create background + val background = Paint() + background.style = Paint.Style.FILL + if (backgroundColor != -1) { + background.color = backgroundColor + } else { + background.color = ContextCompat.getColor(context, R.color.default_neutral_dark) + } + + // create empty bitmap and canvas + val outputImage: Bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val imageCanvas = Canvas(outputImage) + + // draw square background + val right = size.toFloat() + val bottom = size.toFloat() + imageCanvas.drawRect(0f, 0f, right, bottom, background) + + // draw input image onto canvas using transformation matrix + val paint = Paint() + paint.isFilterBitmap = true + imageCanvas.drawBitmap( + bitmap, + createTransformationMatrix( + size, + bitmap.height.toFloat(), + bitmap.width.toFloat(), + adaptivePadding + ), + paint + ) + return outputImage + } + + + /* Extracts color from an image */ + fun getMainColor(context: Context, imageUri: Uri): Int { + // extract color palette from station image + val palette: Palette = + Palette.from(decodeSampledBitmapFromUri(context, imageUri, 72, 72)).generate() + // get muted and vibrant swatches + val vibrantSwatch = palette.vibrantSwatch + val mutedSwatch = palette.mutedSwatch + + when { + vibrantSwatch != null -> { + // return vibrant color + val rgb = vibrantSwatch.rgb + return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb)) + } + mutedSwatch != null -> { + // return muted color + val rgb = mutedSwatch.rgb + return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb)) + } + else -> { + // default return + return context.resources.getColor(R.color.default_neutral_medium_light, null) + } + } + } + + + /* Return sampled down image for given Uri */ + private fun decodeSampledBitmapFromUri( + context: Context, + imageUri: Uri, + reqWidth: Int, + reqHeight: Int + ): Bitmap { + var bitmap: Bitmap? = null + if (imageUri.toString().isNotEmpty()) { + try { + // first decode with inJustDecodeBounds=true to check dimensions + var stream: InputStream? = context.contentResolver.openInputStream(imageUri) + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(stream, null, options) + stream?.close() + + // calculate inSampleSize + options.inSampleSize = calculateSampleParameter(options, reqWidth, reqHeight) + + // decode bitmap with inSampleSize set + stream = context.contentResolver.openInputStream(imageUri) + options.inJustDecodeBounds = false + bitmap = BitmapFactory.decodeStream(stream, null, options) + stream?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + + // get default image + if (bitmap == null) { + bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!! + .toBitmap() + } + + return bitmap + } + + + /* Calculates parameter needed to scale image down */ + private fun calculateSampleParameter( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int + ): Int { + // get size of original image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + + val halfHeight = height / 2 + val halfWidth = width / 2 + + // calculates the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width + while (halfHeight / inSampleSize > reqHeight && halfWidth / inSampleSize > reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + + /* Creates a transformation matrix with the given size and optional padding */ + private fun createTransformationMatrix( + size: Int, + inputImageHeight: Float, + inputImageWidth: Float, + scaled: Boolean + ): Matrix { + val matrix = Matrix() + + // calculate padding + var padding = 0f + if (scaled) { + padding = size.toFloat() / 4f + } + + // define variables needed for transformation matrix + val aspectRatio: Float + val xTranslation: Float + val yTranslation: Float + + // landscape format and square + if (inputImageWidth >= inputImageHeight) { + aspectRatio = (size - padding * 2) / inputImageWidth + xTranslation = 0.0f + padding + yTranslation = (size - inputImageHeight * aspectRatio) / 2.0f + } else { + aspectRatio = (size - padding * 2) / inputImageHeight + yTranslation = 0.0f + padding + xTranslation = (size - inputImageWidth * aspectRatio) / 2.0f + } + + // construct transformation matrix + matrix.postTranslate(xTranslation, yTranslation) + matrix.preScale(aspectRatio, aspectRatio) + return matrix + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt b/app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt new file mode 100644 index 0000000..874e302 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt @@ -0,0 +1,42 @@ +/* + * ImportHelper.kt + * Implements the ImportHelper object + * A ImportHelper provides methods for integrating station files from Radio v3 + * + * 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 com.michatec.radio.Keys +import com.michatec.radio.core.Collection + + +/* + * ImportHelper object + */ +object ImportHelper { + + + /* */ + fun removeDefaultStationImageUris(context: Context) { + val collection: Collection = FileHelper.readCollection(context) + collection.stations.forEach { station -> + if (station.image == Keys.LOCATION_DEFAULT_STATION_IMAGE) { + station.image = String() + } + if (station.smallImage == Keys.LOCATION_DEFAULT_STATION_IMAGE) { + station.smallImage = String() + } + } + CollectionHelper.saveCollection(context, collection, async = false) + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt b/app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt new file mode 100644 index 0000000..1ce41c7 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt @@ -0,0 +1,167 @@ +/* + * NetworkHelper.kt + * Implements the NetworkHelper object + * A NetworkHelper provides helper methods for network related operations + * + * 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.net.ConnectivityManager +import android.net.Network +import android.util.Log +import com.michatec.radio.Keys +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.URL +import java.net.UnknownHostException +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + + +/* + * NetworkHelper object + */ +object NetworkHelper { + + /* Define log tag */ + private val TAG: String = NetworkHelper::class.java.simpleName + + /* Data class: holder for content type information */ + data class ContentType(var type: String = String(), var charset: String = String()) + + + /* Checks if the active network connection is connected to any network */ + fun isConnectedToNetwork(context: Context): Boolean { + val connMgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork: Network? = connMgr.activeNetwork + return activeNetwork != null + } + + + /* Detects content type (mime type) from given URL string - async using coroutine - use only on separate threat */ + fun detectContentType(urlString: String): ContentType { + Log.v(TAG, "Determining content type - Thread: ${Thread.currentThread().name}") + val contentType = ContentType(Keys.MIME_TYPE_UNSUPPORTED, Keys.CHARSET_UNDEFINDED) + val connection: HttpURLConnection? = createConnection(urlString) + if (connection != null) { + val contentTypeHeader: String = connection.contentType ?: String() + Log.v(TAG, "Raw content type header: $contentTypeHeader") + val contentTypeHeaderParts: List = contentTypeHeader.split(";") + contentTypeHeaderParts.forEachIndexed { index, part -> + if (index == 0 && part.isNotEmpty()) { + contentType.type = part.trim() + } else if (part.contains("charset=")) { + contentType.charset = part.substringAfter("charset=").trim() + } + } + + // special treatment for octet-stream - try to get content type from file extension + if (contentType.type.contains(Keys.MIME_TYPE_OCTET_STREAM)) { + Log.w(TAG, "Special case \"application/octet-stream\"") + val headerFieldContentDisposition: String? = + connection.getHeaderField("Content-Disposition") + if (headerFieldContentDisposition != null) { + val fileName: String = headerFieldContentDisposition.split("=")[1].replace( + "\"", + "" + ) //getting value after '=' & stripping any "s + contentType.type = FileHelper.getContentTypeFromExtension(fileName) + } else { + Log.i(TAG, "Unable to get file name from \"Content-Disposition\" header field.") + } + } + + connection.disconnect() + } + Log.i(TAG, "content type: ${contentType.type} | character set: ${contentType.charset}") + return contentType + } + + + /* Download playlist - up to 100 lines, with max. 200 characters */ + fun downloadPlaylist(playlistUrlString: String): List { + val lines = mutableListOf() + val connection = URL(playlistUrlString).openConnection() + val reader = connection.getInputStream().bufferedReader() + reader.useLines { sequence -> + sequence.take(100).forEach { line -> + val trimmedLine = line.take(2000) + lines.add(trimmedLine) + } + } + return lines + } + + + /* Suspend function: Detects content type (mime type) from given URL string - async using coroutine */ + suspend fun detectContentTypeSuspended(urlString: String): ContentType { + return suspendCoroutine { cont -> + cont.resume(detectContentType(urlString)) + } + } + + + /* Suspend function: Gets a random radio-browser.info api address - async using coroutine */ + suspend fun getRadioBrowserServerSuspended(): String { + return suspendCoroutine { cont -> + val serverAddress: String = try { + // get all available radio browser servers + val serverAddressList: Array = + InetAddress.getAllByName(Keys.RADIO_BROWSER_API_BASE) + // select a random address + serverAddressList[Random().nextInt(serverAddressList.size)].canonicalHostName + } catch (e: UnknownHostException) { + Keys.RADIO_BROWSER_API_DEFAULT + } + PreferencesHelper.saveRadioBrowserApiAddress(serverAddress) + cont.resume(serverAddress) + } + } + + + /* Creates a http connection from given url string */ + private fun createConnection(urlString: String, redirectCount: Int = 0): HttpURLConnection? { + var connection: HttpURLConnection? = null + + try { + // try to open connection and get status + Log.i(TAG, "Opening http connection.") + connection = URL(urlString).openConnection() as HttpURLConnection + val status = connection.responseCode + + // CHECK for non-HTTP_OK status + if (status != HttpURLConnection.HTTP_OK) { + // CHECK for redirect status + if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) { + val redirectUrl: String = connection.getHeaderField("Location") + connection.disconnect() + if (redirectCount < 5) { + Log.i(TAG, "Following redirect to $redirectUrl") + connection = createConnection(redirectUrl, redirectCount + 1) + } else { + connection = null + Log.e(TAG, "Too many redirects.") + } + } + } + + } catch (e: Exception) { + Log.e(TAG, "Unable to open http connection.") + e.printStackTrace() + } + + return connection + } + + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt b/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt new file mode 100644 index 0000000..11ba901 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt @@ -0,0 +1,283 @@ +/* + * PreferencesHelper.kt + * Implements the PreferencesHelper object + * A PreferencesHelper provides helper methods for the saving and loading values from shared preferences + * + * 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.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.google.gson.Gson +import com.michatec.radio.Keys +import com.michatec.radio.ui.PlayerState +import java.util.* + + +/* + * PreferencesHelper object + */ +object PreferencesHelper { + + + /* Define log tag */ + private val TAG: String = PreferencesHelper::class.java.simpleName + + + /* The sharedPreferences object to be initialized */ + private lateinit var sharedPreferences: SharedPreferences + + /* Initialize a single sharedPreferences object when the app is launched */ + fun Context.initPreferences() { + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + } + + + /* Loads address of radio-browser.info API from shared preferences */ + fun loadRadioBrowserApiAddress(): String { + return sharedPreferences.getString( + Keys.PREF_RADIO_BROWSER_API, + Keys.RADIO_BROWSER_API_DEFAULT + ) ?: Keys.RADIO_BROWSER_API_DEFAULT + } + + + /* Saves address of radio-browser.info API to shared preferences */ + fun saveRadioBrowserApiAddress(radioBrowserApi: String) { + sharedPreferences.edit { + putString(Keys.PREF_RADIO_BROWSER_API, radioBrowserApi) + } + } + + + /* Saves state of playback for player to shared preferences */ + fun saveIsPlaying(isPlaying: Boolean) { + sharedPreferences.edit { + putBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, isPlaying) + } + } + + + /* Load uuid of the station in the station list which is currently expanded */ + fun loadStationListStreamUuid(): String { + return sharedPreferences.getString(Keys.PREF_STATION_LIST_EXPANDED_UUID, String()) + ?: String() + } + + + /* Save uuid of the station in the station list which is currently expanded */ + fun saveStationListStreamUuid(stationUuid: String = String()) { + sharedPreferences.edit { + putString(Keys.PREF_STATION_LIST_EXPANDED_UUID, stationUuid) + } + } + + + /* Saves last update to shared preferences */ + fun saveLastUpdateCollection(lastUpdate: Date = Calendar.getInstance().time) { + sharedPreferences.edit { + putString(Keys.PREF_LAST_UPDATE_COLLECTION, DateTimeHelper.convertToRfc2822(lastUpdate)) + } + } + + + /* Loads size of collection from shared preferences */ + fun loadCollectionSize(): Int { + return sharedPreferences.getInt(Keys.PREF_COLLECTION_SIZE, -1) + } + + + /* Saves site of collection to shared preferences */ + fun saveCollectionSize(size: Int) { + sharedPreferences.edit { + putInt(Keys.PREF_COLLECTION_SIZE, size) + } + } + + + /* Saves state of sleep timer to shared preferences */ + fun saveSleepTimerRunning(isRunning: Boolean) { + sharedPreferences.edit { + putBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, isRunning) + } + } + + + /* Loads date of last save operation from shared preferences */ + fun loadCollectionModificationDate(): Date { + val modificationDateString: String = + sharedPreferences.getString(Keys.PREF_COLLECTION_MODIFICATION_DATE, "") ?: String() + return DateTimeHelper.convertFromRfc2822(modificationDateString) + } + + + /* Saves date of last save operation to shared preferences */ + fun saveCollectionModificationDate(lastSave: Date = Calendar.getInstance().time) { + sharedPreferences.edit { + putString( + Keys.PREF_COLLECTION_MODIFICATION_DATE, + DateTimeHelper.convertToRfc2822(lastSave) + ) + } + } + + + /* Loads active downloads from shared preferences */ + fun loadActiveDownloads(): String { + val activeDownloadsString: String = + sharedPreferences.getString(Keys.PREF_ACTIVE_DOWNLOADS, Keys.ACTIVE_DOWNLOADS_EMPTY) + ?: Keys.ACTIVE_DOWNLOADS_EMPTY + Log.v(TAG, "IDs of active downloads: $activeDownloadsString") + return activeDownloadsString + } + + + /* Saves active downloads to shared preferences */ + fun saveActiveDownloads(activeDownloadsString: String = String()) { + sharedPreferences.edit { + putString(Keys.PREF_ACTIVE_DOWNLOADS, activeDownloadsString) + } + } + + + /* Loads state of player user interface from shared preferences */ + fun loadPlayerState(): PlayerState { + return PlayerState().apply { + stationUuid = sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String()) + ?: String() + isPlaying = sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, false) + sleepTimerRunning = + sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, false) + } + } + + + /* Saves Uuid if currently playing station to shared preferences */ + fun saveCurrentStationId(stationUuid: String) { + sharedPreferences.edit { + putString(Keys.PREF_PLAYER_STATE_STATION_UUID, stationUuid) + } + } + + + /* Loads uuid of last played station from shared preferences */ + fun loadLastPlayedStationUuid(): String { + return sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String()) + ?: String() + } + + + /* Saves history of metadata in shared preferences */ + fun saveMetadataHistory(metadataHistory: MutableList) { + val gson = Gson() + val json = gson.toJson(metadataHistory) + sharedPreferences.edit { + putString(Keys.PREF_PLAYER_METADATA_HISTORY, json) + } + } + + + /* Loads history of metadata from shared preferences */ + fun loadMetadataHistory(): MutableList { + var metadataHistory: MutableList = mutableListOf() + val json: String = + sharedPreferences.getString(Keys.PREF_PLAYER_METADATA_HISTORY, String()) ?: String() + if (json.isNotEmpty()) { + val gson = Gson() + metadataHistory = gson.fromJson(json, metadataHistory::class.java) + } + return metadataHistory + } + + + /* Start watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */ + fun registerPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + } + + + /* Stop watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */ + fun unregisterPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + + + /* Checks if housekeeping work needs to be done - used usually in DownloadWorker "REQUEST_UPDATE_COLLECTION" */ + fun isHouseKeepingNecessary(): Boolean { + return sharedPreferences.getBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, true) + } + + + /* Saves state of housekeeping */ + fun saveHouseKeepingNecessaryState(state: Boolean = false) { + sharedPreferences.edit { + putBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, state) + } + } + + + /* Load currently selected app theme */ + fun loadThemeSelection(): String { + return sharedPreferences.getString( + Keys.PREF_THEME_SELECTION, + Keys.STATE_THEME_FOLLOW_SYSTEM + ) ?: Keys.STATE_THEME_FOLLOW_SYSTEM + } + + + /* Loads value of the option: Edit Stations */ + fun loadEditStationsEnabled(): Boolean { + return sharedPreferences.getBoolean(Keys.PREF_EDIT_STATIONS, true) + } + + + /* Saves value of the option: Edit Stations (only needed for migration) */ + fun saveEditStationsEnabled(enabled: Boolean = false) { + sharedPreferences.edit { + putBoolean(Keys.PREF_EDIT_STATIONS, enabled) + } + } + + + /* Loads value of the option: Edit Station Streams */ + fun loadEditStreamUrisEnabled(): Boolean { + return sharedPreferences.getBoolean(Keys.PREF_EDIT_STREAMS_URIS, true) + } + + + /* Loads value of the option: Buffer Size */ + fun loadLargeBufferSize(): Boolean { + return sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false) + } + + + /* Loads a multiplier value for constructing the load control */ + fun loadBufferSizeMultiplier(): Int { + return if (sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)) { + Keys.LARGE_BUFFER_SIZE_MULTIPLIER + } else { + 1 + } + } + + + /* Return whether to download over mobile */ + fun downloadOverMobile(): Boolean { + return sharedPreferences.getBoolean( + Keys.PREF_DOWNLOAD_OVER_MOBILE, + Keys.DEFAULT_DOWNLOAD_OVER_MOBILE + ) + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/ShortcutHelper.kt b/app/src/main/java/com/michatec/radio/helpers/ShortcutHelper.kt new file mode 100644 index 0000000..0937d8b --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/ShortcutHelper.kt @@ -0,0 +1,99 @@ +/* + * ShortcutHelper.kt + * Implements the ShortcutHelper object + * A ShortcutHelper creates and handles station shortcuts on the Home screen + * + * 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.graphics.Bitmap +import android.os.Build +import android.widget.Toast +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.net.toUri +import com.michatec.radio.Keys +import com.michatec.radio.MainActivity +import com.michatec.radio.R +import com.michatec.radio.core.Station + + +/* + * ShortcutHelper object + */ +object ShortcutHelper { + + /* Places shortcut on Home screen */ + fun placeShortcut(context: Context, station: Station) { + // credit: https://medium.com/@BladeCoder/using-support-library-26-0-0-you-can-do-bb75911e01e8 + if (ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + val shortcut: ShortcutInfoCompat = ShortcutInfoCompat.Builder(context, station.name) + .setShortLabel(station.name) + .setLongLabel(station.name) + .setIcon(createShortcutIcon(context, station.image, station.imageColor)) + .setIntent(createShortcutIntent(context, station.uuid)) + .build() + ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + Toast.makeText(context, R.string.toastmessage_shortcut_created, Toast.LENGTH_LONG) + .show() + } else { + Toast.makeText(context, R.string.toastmessage_shortcut_not_created, Toast.LENGTH_LONG) + .show() + } + } + + + /* Creates Intent for a station shortcut */ + private fun createShortcutIntent(context: Context, stationUuid: String): Intent { + val shortcutIntent = Intent(context, MainActivity::class.java) + shortcutIntent.action = Keys.ACTION_START + shortcutIntent.putExtra(Keys.EXTRA_STATION_UUID, stationUuid) + shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + return shortcutIntent + } + + + /* Create shortcut icon */ + private fun createShortcutIcon( + context: Context, + stationImage: String, + stationImageColor: Int + ): IconCompat { + val stationImageBitmap: Bitmap = + ImageHelper.getScaledStationImage(context, stationImage.toUri(), 192) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + IconCompat.createWithAdaptiveBitmap( + ImageHelper.createSquareImage( + context, + stationImageBitmap, + stationImageColor, + 192, + true + ) + ) + } else { + IconCompat.createWithAdaptiveBitmap( + ImageHelper.createSquareImage( + context, + stationImageBitmap, + stationImageColor, + 192, + false + ) + ) + } + } + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/UiHelper.kt b/app/src/main/java/com/michatec/radio/helpers/UiHelper.kt new file mode 100644 index 0000000..f395100 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/UiHelper.kt @@ -0,0 +1,282 @@ +/* + * UiHelper.kt + * Implements the UiHelper object + * A UiHelper provides helper methods for User Interface related tasks + * + * 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.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.michatec.radio.Keys +import com.michatec.radio.R + + +/* + * UiHelper object + */ +object UiHelper { + + /* Sets layout margins for given view in DP */ + fun setViewMargins( + context: Context, + view: View, + left: Int = 0, + right: Int = 0, + top: Int = 0, + bottom: Int = 0 + ) { + val l: Int = (left * ImageHelper.getDensityScalingFactor(context)).toInt() + val r: Int = (right * ImageHelper.getDensityScalingFactor(context)).toInt() + val t: Int = (top * ImageHelper.getDensityScalingFactor(context)).toInt() + val b: Int = (bottom * ImageHelper.getDensityScalingFactor(context)).toInt() + if (view.layoutParams is ViewGroup.MarginLayoutParams) { + val p = view.layoutParams as ViewGroup.MarginLayoutParams + p.setMargins(l, t, r, b) + view.requestLayout() + } + } + + + /* Hide keyboard */ + fun hideSoftKeyboard(context: Context, view: View) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } + + + /* + * Inner class: Callback that detects a swipe to left + * Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt + */ + abstract class SwipeToDeleteCallback(context: Context) : + ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { + + private val deleteIcon = ContextCompat.getDrawable(context, R.drawable.ic_remove_circle_24dp) + private val intrinsicWidth: Int = deleteIcon?.intrinsicWidth ?: 0 + private val intrinsicHeight: Int = deleteIcon?.intrinsicHeight ?: 0 + private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_delete_background) + private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } + private val cornerRadius: Float = dpToPx(context) + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + // disable swipe for the add new card + if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) { + return 0 + } + return super.getMovementFlags(recyclerView, viewHolder) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + // do nothing + return false + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + val itemView = viewHolder.itemView + val itemHeight = itemView.bottom - itemView.top + val isCanceled = dX == 0f && !isCurrentlyActive + + if (isCanceled) { + clearCanvas( + c, + itemView.right + dX, + itemView.top.toFloat(), + itemView.right.toFloat(), + itemView.bottom.toFloat() + ) + super.onChildDraw( + c, + recyclerView, + viewHolder, + dX, + dY, + actionState, + false + ) + return + } + + // draw delete and rounded background + val roundedBackground = RectF( + itemView.left.toFloat(), + itemView.top.toFloat(), + itemView.right.toFloat(), + itemView.bottom.toFloat() + ) + + val paint = Paint() + paint.color = backgroundColor + c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint) + + // calculate position of delete icon + val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2 + val deleteIconMargin = (itemHeight - intrinsicHeight) / 2 + val deleteIconLeft = itemView.right - deleteIconMargin - intrinsicWidth + val deleteIconRight = itemView.right - deleteIconMargin + val deleteIconBottom = deleteIconTop + intrinsicHeight + + // draw delete icon + deleteIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom) + deleteIcon?.draw(c) + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + + private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) { + c?.drawRect(left, top, right, bottom, clearPaint) + } + + // conversion from dp to px + private fun dpToPx(context: Context): Float { + val density = context.resources.displayMetrics.density + return 24 * density + } + } + /* + * End of inner class + */ + + + /* + * Inner class: Callback that detects a swipe to left + * Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt + */ + abstract class SwipeToMarkStarredCallback(context: Context) : + ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { + + private val starIcon = ContextCompat.getDrawable(context, R.drawable.ic_favorite_24dp) + private val intrinsicWidth: Int = starIcon?.intrinsicWidth ?: 0 + private val intrinsicHeight: Int = starIcon?.intrinsicHeight ?: 0 + private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_mark_starred_background) + private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } + private val cornerRadius: Float = dpToPx(context) + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + // disable swipe for the add new card + if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) { + return 0 + } + return super.getMovementFlags(recyclerView, viewHolder) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + // do nothing + return false + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + val itemView = viewHolder.itemView + val itemHeight = itemView.bottom - itemView.top + val isCanceled = dX == 0f && !isCurrentlyActive + + if (isCanceled) { + clearCanvas( + c, + itemView.right + dX, + itemView.top.toFloat(), + itemView.right.toFloat(), + itemView.bottom.toFloat() + ) + super.onChildDraw( + c, + recyclerView, + viewHolder, + dX, + dY, + actionState, + false + ) + return + } + + // draw favorite color and rounded background + val roundedBackground = RectF( + itemView.left.toFloat(), + itemView.top.toFloat(), + itemView.right.toFloat(), + itemView.bottom.toFloat() + ) + + val paint = Paint() + paint.color = backgroundColor + c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint) + + // calculate position of delete icon + val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2 + val deleteIconMargin = (itemHeight - intrinsicHeight) / 2 + val deleteIconLeft = itemView.left + deleteIconMargin + val deleteIconRight = itemView.left + deleteIconMargin + intrinsicWidth + val deleteIconBottom = deleteIconTop + intrinsicHeight + + // draw delete icon + starIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom) + starIcon?.draw(c) + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + + private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) { + c?.drawRect(left, top, right, bottom, clearPaint) + } + + // conversion from dp to px + private fun dpToPx(context: Context): Float { + val density = context.resources.displayMetrics.density + return 24 * density + } + } + /* + * End of inner class + */ + + +} diff --git a/app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt b/app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt new file mode 100644 index 0000000..fc633c5 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt @@ -0,0 +1,142 @@ +/* + * UpdateHelper.kt + * Implements the UpdateHelper class + * A UpdateHelper provides methods to update a single station or the whole 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.util.Log +import com.michatec.radio.Keys +import com.michatec.radio.core.Collection +import com.michatec.radio.core.Station +import com.michatec.radio.search.RadioBrowserResult +import com.michatec.radio.search.RadioBrowserSearch +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main + + +/* + * UpdateHelper class + */ +class UpdateHelper( + private val context: Context, + private val updateHelperListener: UpdateHelperListener, + private var collection: Collection +) : RadioBrowserSearch.RadioBrowserSearchListener { + + + /* Define log tag */ + private val TAG: String = UpdateHelper::class.java.simpleName + + + /* Main class variables */ + private var radioBrowserSearchCounter: Int = 0 + private var remoteStationLocationsList: MutableList = mutableListOf() + + + /* Listener Interface */ + interface UpdateHelperListener { + fun onStationUpdated( + collection: Collection, + positionPriorUpdate: Int, + positionAfterUpdate: Int + ) + } + + + /* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */ + override fun onRadioBrowserSearchResults(results: Array) { + if (results.isNotEmpty()) { + CoroutineScope(IO).launch { + // get station from results + val station: Station = results[0].toStation() + // detect content type + val deferred: Deferred = + async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) } + // wait for result + val contentType: NetworkHelper.ContentType = deferred.await() + // update content type + station.streamContent = contentType.type + // get position + val positionPriorUpdate = + CollectionHelper.getStationPositionFromRadioBrowserStationUuid( + collection, + station.radioBrowserStationUuid + ) + // update (and sort) collection + collection = CollectionHelper.updateStation(context, collection, station) + // get new position + val positionAfterUpdate: Int = + CollectionHelper.getStationPositionFromRadioBrowserStationUuid( + collection, + station.radioBrowserStationUuid + ) + // hand over results + withContext(Main) { + updateHelperListener.onStationUpdated( + collection, + positionPriorUpdate, + positionAfterUpdate + ) + } + // decrease counter + radioBrowserSearchCounter-- + // all downloads from radio browser succeeded + if (radioBrowserSearchCounter == 0 && remoteStationLocationsList.isNotEmpty()) { + // direct download of playlists + DownloadHelper.downloadPlaylists( + context, + remoteStationLocationsList.toTypedArray() + ) + } + } + } + } + + + /* Updates the whole collection of stations */ + fun updateCollection() { + PreferencesHelper.saveLastUpdateCollection() + collection.stations.forEach { station -> + when { + station.radioBrowserStationUuid.isNotEmpty() -> { + // increase counter + radioBrowserSearchCounter++ + // request download from radio browser + downloadFromRadioBrowser(station.radioBrowserStationUuid) + } + station.remoteStationLocation.isNotEmpty() -> { + // add playlist link to list for later(!) download in onRadioBrowserSearchResults + remoteStationLocationsList.add(station.remoteStationLocation) + } + else -> { + Log.w(TAG, "Unable to update station: ${station.name}.") + } + } + } + // special case: collection contained only playlist files + if (radioBrowserSearchCounter == 0) { + // direct download of playlists + DownloadHelper.downloadPlaylists(context, remoteStationLocationsList.toTypedArray()) + } + } + + + /* Get updated station from radio browser - results are handled by onRadioBrowserSearchResults */ + private fun downloadFromRadioBrowser(radioBrowserStationUuid: String) { + val radioBrowserSearch = RadioBrowserSearch(this) + radioBrowserSearch.searchStation(context, radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID) + } + +} diff --git a/app/src/main/java/com/michatec/radio/search/DirectInputCheck.kt b/app/src/main/java/com/michatec/radio/search/DirectInputCheck.kt new file mode 100644 index 0000000..a6cbf2f --- /dev/null +++ b/app/src/main/java/com/michatec/radio/search/DirectInputCheck.kt @@ -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) { + } + } + + + /* 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 = 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) { + 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 + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/michatec/radio/search/RadioBrowserResult.kt b/app/src/main/java/com/michatec/radio/search/RadioBrowserResult.kt new file mode 100644 index 0000000..57f03a2 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/search/RadioBrowserResult.kt @@ -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. + */ diff --git a/app/src/main/java/com/michatec/radio/search/RadioBrowserSearch.kt b/app/src/main/java/com/michatec/radio/search/RadioBrowserSearch.kt new file mode 100644 index 0000000..31115c2 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/search/RadioBrowserSearch.kt @@ -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) { + } + } + + + /* 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 { + val params = HashMap() + 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 { + val gsonBuilder = GsonBuilder() + gsonBuilder.setDateFormat("M/d/yy hh:mm a") + val gson = gsonBuilder.create() + return gson.fromJson(result, Array::class.java) + } + + + /* Updates the address of the radio-browser.info api */ + private fun updateRadioBrowserApi() { + CoroutineScope(IO).launch { + val deferred: Deferred = async { NetworkHelper.getRadioBrowserServerSuspended() } + radioBrowserApi = deferred.await() + } + } + + + /* Listens for (positive) server responses to search requests */ + private val responseListener: Response.Listener = Response.Listener { 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") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt b/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt new file mode 100644 index 0000000..174e91a --- /dev/null +++ b/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt @@ -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) : RecyclerView.Adapter() { + + /* 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) + } + +} diff --git a/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt b/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt new file mode 100644 index 0000000..2fb9135 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt @@ -0,0 +1,454 @@ +/* + * LayoutHolder.kt + * Implements the LayoutHolder class + * A LayoutHolder hold references to the main views + * + * 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.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.drawable.AnimatedVectorDrawable +import android.os.Build +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.Group +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.michatec.radio.Keys +import com.michatec.radio.R +import com.michatec.radio.core.Station +import com.michatec.radio.helpers.DateTimeHelper +import com.michatec.radio.helpers.ImageHelper +import com.michatec.radio.helpers.PreferencesHelper +import com.michatec.radio.helpers.UiHelper + + +/* + * LayoutHolder class + */ +data class LayoutHolder(var rootView: View) { + + /* Main class variables */ + var recyclerView: RecyclerView = rootView.findViewById(R.id.station_list) + val layoutManager: LinearLayoutManager + private var bottomSheet: ConstraintLayout = rootView.findViewById(R.id.bottom_sheet) + + //private var sheetMetadataViews: Group + private var sleepTimerRunningViews: Group = rootView.findViewById(R.id.sleep_timer_running_views) + private var downloadProgressIndicator: ProgressBar = rootView.findViewById(R.id.download_progress_indicator) + private var stationImageView: ImageView = rootView.findViewById(R.id.station_icon) + private var stationNameView: TextView = rootView.findViewById(R.id.player_station_name) + private var metadataView: TextView = rootView.findViewById(R.id.player_station_metadata) + var playButtonView: ImageButton = rootView.findViewById(R.id.player_play_button) + private var bufferingIndicator: ProgressBar = rootView.findViewById(R.id.player_buffering_indicator) + private var sheetStreamingLinkHeadline: TextView = rootView.findViewById(R.id.sheet_streaming_link_headline) + private var sheetStreamingLinkView: TextView = rootView.findViewById(R.id.sheet_streaming_link) + private var sheetMetadataHistoryHeadline: TextView = rootView.findViewById(R.id.sheet_metadata_headline) + private var sheetMetadataHistoryView: TextView = rootView.findViewById(R.id.sheet_metadata_history) + private var sheetNextMetadataView: ImageButton = rootView.findViewById(R.id.sheet_next_metadata_button) + private var sheetPreviousMetadataView: ImageButton = rootView.findViewById(R.id.sheet_previous_metadata_button) + private var sheetCopyMetadataButtonView: ImageButton = rootView.findViewById(R.id.copy_station_metadata_button) + private var sheetShareLinkButtonView: ImageView = rootView.findViewById(R.id.sheet_share_link_button) + private var sheetBitrateView: TextView = rootView.findViewById(R.id.sheet_bitrate_view) + var sheetSleepTimerStartButtonView: ImageButton = rootView.findViewById(R.id.sleep_timer_start_button) + var sheetSleepTimerCancelButtonView: ImageButton = rootView.findViewById(R.id.sleep_timer_cancel_button) + private var sheetSleepTimerRemainingTimeView: TextView = rootView.findViewById(R.id.sleep_timer_remaining_time) + private var onboardingLayout: ConstraintLayout = rootView.findViewById(R.id.onboarding_layout) + private var bottomSheetBehavior: BottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) + private var metadataHistory: MutableList + private var metadataHistoryPosition: Int + private var isBuffering: Boolean + + + /* Init block */ + init { + // find views + //sheetMetadataViews = rootView.findViewById(R.id.sheet_metadata_views) + metadataHistory = PreferencesHelper.loadMetadataHistory() + metadataHistoryPosition = metadataHistory.size - 1 + isBuffering = false + + // set up RecyclerView + layoutManager = CustomLayoutManager(rootView.context) + recyclerView.layoutManager = layoutManager + recyclerView.itemAnimator = DefaultItemAnimator() + + // set up metadata history next and previous buttons + sheetPreviousMetadataView.setOnClickListener { + if (metadataHistory.isNotEmpty()) { + if (metadataHistoryPosition > 0) { + metadataHistoryPosition -= 1 + } else { + metadataHistoryPosition = metadataHistory.size - 1 + } + sheetMetadataHistoryView.text = metadataHistory[metadataHistoryPosition] + } + } + sheetNextMetadataView.setOnClickListener { + if (metadataHistory.isNotEmpty()) { + if (metadataHistoryPosition < metadataHistory.size - 1) { + metadataHistoryPosition += 1 + } else { + metadataHistoryPosition = 0 + } + sheetMetadataHistoryView.text = metadataHistory[metadataHistoryPosition] + } + } + sheetMetadataHistoryView.setOnLongClickListener { + copyMetadataHistoryToClipboard() + return@setOnLongClickListener true + } + sheetMetadataHistoryHeadline.setOnLongClickListener { + copyMetadataHistoryToClipboard() + return@setOnLongClickListener true + } + + // set layout for player + setupBottomSheet() + } + + + /* Updates the player views */ + fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) { + + // set default metadata views, when playback has stopped + if (!isPlaying) { + metadataView.text = station.name + sheetMetadataHistoryView.text = station.name +// sheetMetadataHistoryView.isSelected = true + } + + // update name + stationNameView.text = station.name + + // toggle text scrolling (marquee) if necessary + stationNameView.isSelected = isPlaying + + // reduce the shadow left and right because of scrolling (Marquee) + stationNameView.setFadingEdgeLength(8) + + // update cover + if (station.imageColor != -1) { + stationImageView.setBackgroundColor(station.imageColor) + } + stationImageView.setImageBitmap(ImageHelper.getStationImage(context, station.smallImage)) + stationImageView.contentDescription = "${context.getString(R.string.descr_player_station_image)}: ${station.name}" + + // update streaming link + sheetStreamingLinkView.text = station.getStreamUri() + + val bitrateText: CharSequence = if (station.codec.isNotEmpty()) { + if (station.bitrate == 0) { + // show only the codec when the bitrate is at "0" from radio-browser.info API + station.codec + } else { + // show the bitrate and codec if the result is available in the radio-browser.info API + buildString { + append(station.codec) + append(" | ") + append(station.bitrate) + append("kbps") + } + } + } else { + // do not show for M3U and PLS playlists as they do not include codec or bitrate + "" + } + + // update bitrate + sheetBitrateView.text = bitrateText + + // update click listeners + sheetStreamingLinkHeadline.setOnClickListener { + copyToClipboard( + context, + sheetStreamingLinkView.text + ) + } + sheetStreamingLinkView.setOnClickListener { + copyToClipboard( + context, + sheetStreamingLinkView.text + ) + } + sheetMetadataHistoryHeadline.setOnClickListener { + copyToClipboard( + context, + sheetMetadataHistoryView.text + ) + } + sheetMetadataHistoryView.setOnClickListener { + copyToClipboard( + context, + sheetMetadataHistoryView.text + ) + } + sheetCopyMetadataButtonView.setOnClickListener { + copyToClipboard( + context, + sheetMetadataHistoryView.text + ) + } + sheetBitrateView.setOnClickListener { + copyToClipboard( + context, + sheetBitrateView.text + ) + } + sheetShareLinkButtonView.setOnClickListener { + val share = Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TITLE, stationNameView.text) + putExtra(Intent.EXTRA_TEXT, sheetStreamingLinkView.text) + type = "text/plain" + }, null) + context.startActivity(share) + } + } + + + /* Copies given string to clipboard */ + private fun copyToClipboard(context: Context, clipString: CharSequence) { + val clip: ClipData = ClipData.newPlainText("simple text", clipString) + val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // since API 33 (TIRAMISU) the OS displays its own notification when content is copied to the clipboard + Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show() + } + } + + + /* Copies collected metadata to clipboard */ + private fun copyMetadataHistoryToClipboard() { + val metadataHistory: MutableList = PreferencesHelper.loadMetadataHistory() + val stringBuilder: StringBuilder = StringBuilder() + metadataHistory.forEach { stringBuilder.append("${it.trim()}\n") } + copyToClipboard(rootView.context, stringBuilder.toString()) + } + + + /* Updates the metadata views */ + fun updateMetadata(metadataHistoryList: MutableList?) { + if (!metadataHistoryList.isNullOrEmpty()) { + metadataHistory = metadataHistoryList + if (metadataHistory.last() != metadataView.text) { + metadataHistoryPosition = metadataHistory.size - 1 + val metadataString = metadataHistory[metadataHistoryPosition] + metadataView.text = metadataString + sheetMetadataHistoryView.text = metadataString + } + } + } + + + /* Updates sleep timer views */ + fun updateSleepTimer(context: Context, timeRemaining: Long = 0L) { + when (timeRemaining) { + 0L -> { + sleepTimerRunningViews.isGone = true + } + else -> { + sleepTimerRunningViews.isVisible = true + val sleepTimerTimeRemaining = DateTimeHelper.convertToHoursMinutesSeconds(timeRemaining) + sheetSleepTimerRemainingTimeView.text = sleepTimerTimeRemaining + sheetSleepTimerRemainingTimeView.contentDescription = "${context.getString(R.string.descr_expanded_player_sleep_timer_remaining_time)}: $sleepTimerTimeRemaining" + stationNameView.isSelected = false + } + } + } + + + /* Toggles play/pause button */ + fun togglePlayButton(isPlaying: Boolean) { + if (isPlaying) { + playButtonView.setImageResource(R.drawable.ic_audio_waves_animated) + val animatedVectorDrawable = playButtonView.drawable as? AnimatedVectorDrawable + animatedVectorDrawable?.start() + sheetSleepTimerStartButtonView.isVisible = true + // bufferingIndicator.isVisible = false + } else { + playButtonView.setImageResource(R.drawable.ic_player_play_symbol_42dp) + sheetSleepTimerStartButtonView.isVisible = false + // bufferingIndicator.isVisible = isBuffering + } + } + + + /* Toggles buffering indicator */ + fun showBufferingIndicator(buffering: Boolean) { + bufferingIndicator.isVisible = buffering + isBuffering = buffering + } + + + /* Toggles visibility of player depending on playback state - hiding it when playback is stopped (not paused or playing) */ +// fun togglePlayerVisibility(context: Context, playbackState: Int): Boolean { +// when (playbackState) { +// PlaybackStateCompat.STATE_STOPPED -> return hidePlayer(context) +// PlaybackStateCompat.STATE_NONE -> return hidePlayer(context) +// PlaybackStateCompat.STATE_ERROR -> return hidePlayer(context) +// else -> return showPlayer(context) +// } +// } + + + /* Toggles visibility of the download progress indicator */ + fun toggleDownloadProgressIndicator() { + when (PreferencesHelper.loadActiveDownloads()) { + Keys.ACTIVE_DOWNLOADS_EMPTY -> downloadProgressIndicator.isGone = true + else -> downloadProgressIndicator.isVisible = true + } + } + + + /* Toggles visibility of the onboarding screen */ + fun toggleOnboarding(context: Context, collectionSize: Int): Boolean { + return if (collectionSize == 0 && PreferencesHelper.loadCollectionSize() <= 0) { + onboardingLayout.isVisible = true + hidePlayer(context) + true + } else { + onboardingLayout.isGone = true + showPlayer(context) + false + } + } + + + /* Initiates the rotation animation of the play button */ + fun animatePlaybackButtonStateTransition(context: Context, isPlaying: Boolean) { + when (isPlaying) { + true -> { + val rotateClockwise = AnimationUtils.loadAnimation(context, R.anim.rotate_clockwise_slow) + rotateClockwise.setAnimationListener(createAnimationListener(true)) + playButtonView.startAnimation(rotateClockwise) + } + false -> { + val rotateCounterClockwise = AnimationUtils.loadAnimation(context, R.anim.rotate_counterclockwise_fast) + rotateCounterClockwise.setAnimationListener(createAnimationListener(false)) + playButtonView.startAnimation(rotateCounterClockwise) + } + + } + } + + + /* Shows player */ + fun showPlayer(context: Context): Boolean { + UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, Keys.BOTTOM_SHEET_PEEK_HEIGHT) + if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_HIDDEN && onboardingLayout.visibility == View.GONE) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + return true + } + + + /* Hides player */ + private fun hidePlayer(context: Context): Boolean { + UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, 0) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return true + } + + + /* Minimizes player sheet if expanded */ + fun minimizePlayerIfExpanded(): Boolean { + return if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + true + } else { + false + } + } + + + /* Creates AnimationListener for play button */ + private fun createAnimationListener(isPlaying: Boolean): Animation.AnimationListener { + return object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) {} + override fun onAnimationEnd(animation: Animation) { + // set up button symbol and playback indicator afterwards + togglePlayButton(isPlaying) + } + + override fun onAnimationRepeat(animation: Animation) {} + } + } + + + /* Sets up the player (BottomSheet) */ + private fun setupBottomSheet() { + // show / hide the small player + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + bottomSheetBehavior.addBottomSheetCallback(object : + BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(view: View, slideOffset: Float) { + } + + override fun onStateChanged(view: View, state: Int) { + when (state) { + BottomSheetBehavior.STATE_COLLAPSED -> Unit // do nothing + BottomSheetBehavior.STATE_DRAGGING -> Unit // do nothing + BottomSheetBehavior.STATE_EXPANDED -> Unit // do nothing + BottomSheetBehavior.STATE_HALF_EXPANDED -> Unit // do nothing + BottomSheetBehavior.STATE_SETTLING -> Unit // do nothing + BottomSheetBehavior.STATE_HIDDEN -> showPlayer(rootView.context) + } + } + }) + // toggle collapsed state on tap + bottomSheet.setOnClickListener { toggleBottomSheetState() } + stationImageView.setOnClickListener { toggleBottomSheetState() } + stationNameView.setOnClickListener { toggleBottomSheetState() } + metadataView.setOnClickListener { toggleBottomSheetState() } + } + + + /* Toggle expanded/collapsed state of bottom sheet */ + private fun toggleBottomSheetState() { + when (bottomSheetBehavior.state) { + BottomSheetBehavior.STATE_COLLAPSED -> bottomSheetBehavior.state = + BottomSheetBehavior.STATE_EXPANDED + else -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + } + + + /* + * Inner class: Custom LinearLayoutManager + */ + private inner class CustomLayoutManager(context: Context) : + LinearLayoutManager(context, VERTICAL, false) { + override fun supportsPredictiveItemAnimations(): Boolean { + return true + } + } + /* + * End of inner class + */ + + +} diff --git a/app/src/main/java/com/michatec/radio/ui/PlayerState.kt b/app/src/main/java/com/michatec/radio/ui/PlayerState.kt new file mode 100644 index 0000000..a1058c6 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/ui/PlayerState.kt @@ -0,0 +1,30 @@ +/* + * PlayerState.kt + * Implements the PlayerState class + * A PlayerState holds parameters describing the state of the player part of the UI + * + * 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.ui + +import android.os.Parcelable +import com.google.gson.annotations.Expose +import kotlinx.parcelize.Parcelize + + +/* + * PlayerState class + */ +@Parcelize +data class PlayerState( + @Expose var stationUuid: String = String(), + @Expose var isPlaying: Boolean = false, + @Expose var sleepTimerRunning: Boolean = false +) : Parcelable diff --git a/app/src/main/res/anim/rotate_clockwise_slow.xml b/app/src/main/res/anim/rotate_clockwise_slow.xml new file mode 100644 index 0000000..d369e1a --- /dev/null +++ b/app/src/main/res/anim/rotate_clockwise_slow.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/rotate_counterclockwise_fast.xml b/app/src/main/res/anim/rotate_counterclockwise_fast.xml new file mode 100644 index 0000000..da11ffc --- /dev/null +++ b/app/src/main/res/anim/rotate_counterclockwise_fast.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/scale_center_y.xml b/app/src/main/res/animator/scale_center_y.xml new file mode 100644 index 0000000..2d6744c --- /dev/null +++ b/app/src/main/res/animator/scale_center_y.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/scale_left_y.xml b/app/src/main/res/animator/scale_left_y.xml new file mode 100644 index 0000000..83b0534 --- /dev/null +++ b/app/src/main/res/animator/scale_left_y.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/scale_right_y.xml b/app/src/main/res/animator/scale_right_y.xml new file mode 100644 index 0000000..c4346fd --- /dev/null +++ b/app/src/main/res/animator/scale_right_y.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_audio_listening.xml b/app/src/main/res/drawable-night/ic_audio_listening.xml new file mode 100644 index 0000000..594d23a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_audio_listening.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_24dp.xml b/app/src/main/res/drawable/ic_add_24dp.xml new file mode 100644 index 0000000..b1e2c53 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_upward_24dp.xml b/app/src/main/res/drawable/ic_arrow_upward_24dp.xml new file mode 100644 index 0000000..f1b26e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_upward_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_audio_listening.xml b/app/src/main/res/drawable/ic_audio_listening.xml new file mode 100644 index 0000000..57a92a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_listening.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_audio_waves_36dp.xml b/app/src/main/res/drawable/ic_audio_waves_36dp.xml new file mode 100644 index 0000000..d0e4241 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_waves_36dp.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_audio_waves_animated.xml b/app/src/main/res/drawable/ic_audio_waves_animated.xml new file mode 100644 index 0000000..41270b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_waves_animated.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_brush_24dp.xml b/app/src/main/res/drawable/ic_brush_24dp.xml new file mode 100644 index 0000000..a641244 --- /dev/null +++ b/app/src/main/res/drawable/ic_brush_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_24dp.xml b/app/src/main/res/drawable/ic_check_24dp.xml new file mode 100644 index 0000000..209154e --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_left_24dp.xml b/app/src/main/res/drawable/ic_chevron_left_24dp.xml new file mode 100644 index 0000000..bca99f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right_24dp.xml b/app/src/main/res/drawable/ic_chevron_right_24dp.xml new file mode 100644 index 0000000..d0f4fca --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_24dp.xml b/app/src/main/res/drawable/ic_clear_24dp.xml new file mode 100644 index 0000000..ac35220 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_codeberg_24dp.xml b/app/src/main/res/drawable/ic_codeberg_24dp.xml new file mode 100644 index 0000000..f211b68 --- /dev/null +++ b/app/src/main/res/drawable/ic_codeberg_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy_content_24dp.xml b/app/src/main/res/drawable/ic_copy_content_24dp.xml new file mode 100644 index 0000000..8c31259 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_content_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_default_station_image_72dp.xml b/app/src/main/res/drawable/ic_default_station_image_72dp.xml new file mode 100644 index 0000000..96ff1cd --- /dev/null +++ b/app/src/main/res/drawable/ic_default_station_image_72dp.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_download_24dp.xml b/app/src/main/res/drawable/ic_download_24dp.xml new file mode 100644 index 0000000..2e18a97 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml new file mode 100644 index 0000000..84673a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_24dp.xml b/app/src/main/res/drawable/ic_favorite_24dp.xml new file mode 100644 index 0000000..b51ae64 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_default_24dp.xml b/app/src/main/res/drawable/ic_favorite_default_24dp.xml new file mode 100644 index 0000000..579707c --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_default_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_github_24dp.xml b/app/src/main/res/drawable/ic_github_24dp.xml new file mode 100644 index 0000000..0c9d32d --- /dev/null +++ b/app/src/main/res/drawable/ic_github_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_home_24dp.xml b/app/src/main/res/drawable/ic_home_24dp.xml new file mode 100644 index 0000000..73b2ec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_image_24dp.xml b/app/src/main/res/drawable/ic_image_24dp.xml new file mode 100644 index 0000000..4124768 --- /dev/null +++ b/app/src/main/res/drawable/ic_image_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_white_36dp.xml b/app/src/main/res/drawable/ic_image_white_36dp.xml new file mode 100644 index 0000000..d7e93a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_image_white_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_24dp.xml b/app/src/main/res/drawable/ic_info_24dp.xml new file mode 100644 index 0000000..945af95 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0a8fe13 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_library_24dp.xml b/app/src/main/res/drawable/ic_library_24dp.xml new file mode 100644 index 0000000..136154f --- /dev/null +++ b/app/src/main/res/drawable/ic_library_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_music_note_24dp.xml b/app/src/main/res/drawable/ic_music_note_24dp.xml new file mode 100644 index 0000000..0818998 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_check_24dp.xml b/app/src/main/res/drawable/ic_network_check_24dp.xml new file mode 100644 index 0000000..4cc7eb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_check_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notification_app_icon_white_24dp.xml b/app/src/main/res/drawable/ic_notification_app_icon_white_24dp.xml new file mode 100644 index 0000000..cdc71be --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_app_icon_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_play_36dp.xml b/app/src/main/res/drawable/ic_notification_play_36dp.xml new file mode 100644 index 0000000..b7ca1c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_play_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_skip_to_next_36dp.xml b/app/src/main/res/drawable/ic_notification_skip_to_next_36dp.xml new file mode 100644 index 0000000..82766f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_skip_to_next_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_skip_to_previous_36dp.xml b/app/src/main/res/drawable/ic_notification_skip_to_previous_36dp.xml new file mode 100644 index 0000000..d9465cb --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_skip_to_previous_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_stop_36dp.xml b/app/src/main/res/drawable/ic_notification_stop_36dp.xml new file mode 100644 index 0000000..cb6b717 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_stop_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_player_play_symbol_42dp.xml b/app/src/main/res/drawable/ic_player_play_symbol_42dp.xml new file mode 100644 index 0000000..3542d71 --- /dev/null +++ b/app/src/main/res/drawable/ic_player_play_symbol_42dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_player_sheet_music_note_24dp.xml b/app/src/main/res/drawable/ic_player_sheet_music_note_24dp.xml new file mode 100644 index 0000000..6247c13 --- /dev/null +++ b/app/src/main/res/drawable/ic_player_sheet_music_note_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_player_stop_symbol_36dp.xml b/app/src/main/res/drawable/ic_player_stop_symbol_36dp.xml new file mode 100644 index 0000000..76ebee2 --- /dev/null +++ b/app/src/main/res/drawable/ic_player_stop_symbol_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_remove_circle_24dp.xml b/app/src/main/res/drawable/ic_remove_circle_24dp.xml new file mode 100644 index 0000000..09b75e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove_circle_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_save_m3u_24dp.xml b/app/src/main/res/drawable/ic_save_m3u_24dp.xml new file mode 100644 index 0000000..e2dbd3b --- /dev/null +++ b/app/src/main/res/drawable/ic_save_m3u_24dp.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_save_pls_24dp.xml b/app/src/main/res/drawable/ic_save_pls_24dp.xml new file mode 100644 index 0000000..a738fb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_pls_24dp.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_settings_24dp.xml b/app/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 0000000..3f14da0 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml new file mode 100644 index 0000000..65972b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shortcut_play_circle_24dp.xml b/app/src/main/res/drawable/ic_shortcut_play_circle_24dp.xml new file mode 100644 index 0000000..3d28c7c --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_play_circle_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sleep_timer_24dp.xml b/app/src/main/res/drawable/ic_sleep_timer_24dp.xml new file mode 100644 index 0000000..8528137 --- /dev/null +++ b/app/src/main/res/drawable/ic_sleep_timer_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_upload_24dp.xml b/app/src/main/res/drawable/ic_upload_24dp.xml new file mode 100644 index 0000000..2fad5ab --- /dev/null +++ b/app/src/main/res/drawable/ic_upload_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/selector_play_button.xml b/app/src/main/res/drawable/selector_play_button.xml new file mode 100644 index 0000000..8fb9568 --- /dev/null +++ b/app/src/main/res/drawable/selector_play_button.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_search_result_item.xml b/app/src/main/res/drawable/selector_search_result_item.xml new file mode 100644 index 0000000..40bf35c --- /dev/null +++ b/app/src/main/res/drawable/selector_search_result_item.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_cover_small.xml b/app/src/main/res/drawable/shape_cover_small.xml new file mode 100644 index 0000000..b80648b --- /dev/null +++ b/app/src/main/res/drawable/shape_cover_small.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_player_button_small.xml b/app/src/main/res/drawable/shape_player_button_small.xml new file mode 100644 index 0000000..c7258a4 --- /dev/null +++ b/app/src/main/res/drawable/shape_player_button_small.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_player_button_small_selected.xml b/app/src/main/res/drawable/shape_player_button_small_selected.xml new file mode 100644 index 0000000..c56ca8b --- /dev/null +++ b/app/src/main/res/drawable/shape_player_button_small_selected.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_player_sheet_background.xml b/app/src/main/res/drawable/shape_player_sheet_background.xml new file mode 100644 index 0000000..730995c --- /dev/null +++ b/app/src/main/res/drawable/shape_player_sheet_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/shape_search_result_item.xml b/app/src/main/res/drawable/shape_search_result_item.xml new file mode 100644 index 0000000..d38456d --- /dev/null +++ b/app/src/main/res/drawable/shape_search_result_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_search_result_item_selected.xml b/app/src/main/res/drawable/shape_search_result_item_selected.xml new file mode 100644 index 0000000..1490556 --- /dev/null +++ b/app/src/main/res/drawable/shape_search_result_item_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ba2ac6d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_sheet_playback_controls.xml b/app/src/main/res/layout/bottom_sheet_playback_controls.xml new file mode 100644 index 0000000..4803f87 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_playback_controls.xml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/card_add_new_station.xml b/app/src/main/res/layout/card_add_new_station.xml new file mode 100644 index 0000000..e7e87bc --- /dev/null +++ b/app/src/main/res/layout/card_add_new_station.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/card_station.xml b/app/src/main/res/layout/card_station.xml new file mode 100644 index 0000000..209f619 --- /dev/null +++ b/app/src/main/res/layout/card_station.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + +