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.
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_add_station.xml b/app/src/main/res/layout/dialog_add_station.xml
new file mode 100644
index 0000000..1193e39
--- /dev/null
+++ b/app/src/main/res/layout/dialog_add_station.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_find_station.xml b/app/src/main/res/layout/dialog_find_station.xml
new file mode 100644
index 0000000..361d77f
--- /dev/null
+++ b/app/src/main/res/layout/dialog_find_station.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_generic_with_details.xml b/app/src/main/res/layout/dialog_generic_with_details.xml
new file mode 100644
index 0000000..56bb14b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_generic_with_details.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/element_onboarding.xml b/app/src/main/res/layout/element_onboarding.xml
new file mode 100644
index 0000000..84d60da
--- /dev/null
+++ b/app/src/main/res/layout/element_onboarding.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/element_search_result.xml b/app/src/main/res/layout/element_search_result.xml
new file mode 100644
index 0000000..e6c56e4
--- /dev/null
+++ b/app/src/main/res/layout/element_search_result.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml
new file mode 100644
index 0000000..90c5145
--- /dev/null
+++ b/app/src/main/res/layout/fragment_player.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/preference_switch.xml b/app/src/main/res/layout/preference_switch.xml
new file mode 100644
index 0000000..ba3fd96
--- /dev/null
+++ b/app/src/main/res/layout/preference_switch.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..0648fb4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..0648fb4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..21766c0
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4df66e3
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..a52e8dc
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..0296178
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..81ef578
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..382dd99
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f32b2ed
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..800823b
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..ade7dcd
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..cfe8b9c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml
new file mode 100644
index 0000000..c861274
--- /dev/null
+++ b/app/src/main/res/navigation/nav_graph_main.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..94282f0
--- /dev/null
+++ b/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,116 @@
+
+
+
+
+ App-Symbol in der Form eines alten Radios
+ Start/Stopp
+ Markierter Sender
+ Senderbild ändern
+ Senderbild
+ Informationen zur aktuellen Wiedergabe kopieren
+ Nächster jetzt abspielender Metadateneintrag
+ Vorheriger abspielender Metadateneintrag
+ Schlaf-Timer abbrechen
+ Verbleibende Zeit des Schlaf-Timers
+ Schlaf-Timer starten
+ Start/Pause
+ Senderbild
+
+ Station hinzufügen
+ Sendername
+ Stream-Adresse
+ Ein Fehler ist aufgetreten
+ Mit dem Internet verbinden.
+ Offline
+ Hinzufügen
+ Name oder Stream-Adresse
+ Keine Ergebnisse gefunden.
+ Sender finden
+ Abbrechen
+ OK
+ Details anzeigen
+ Keine Details verfügbar
+ Soll die aktuelle Sammlung von Radiostationen durch die Radiostationen aus dem Backup ersetzt werden?
+ Diesen Sender entfernen?
+ Neueste Version aller Senderbilder herunterladen\?
+ Ja
+ Entfernen
+ Aktualisieren
+
+ Einstellungen
+
+ Wiedergabesteuerung
+ Wiedergabe
+ Stopp
+ Zurück
+ Nächste
+
+ Tauche ein in den Sound deiner Wahl!
+ Jetzt starten
+
+ Momentan läuft
+ Streaming-Adresse
+
+ Erweitert
+ Version
+ App-Version
+ Importieren & Exportieren
+ Standardpuffergröße für die Wiedergabe eines Radiostreams wird verwendet.
+ Größerer Puffer für die Wiedergabe eines Radiostreams ist aktiviert. Es kann länger dauern, bis die Wiedergabe beginnt.
+ Größeren Puffer verwenden
+ Die Bearbeitung von Streaming-Links ist deaktiviert.
+ Die Bearbeitung von Streaming-Links ist aktiviert. Stelle sicher, dass du eine korrekte Stream-Adresse eingibst.
+ Streaming-Links bearbeiten
+ Die Bearbeitung von Senderinformationen ist deaktiviert.
+ Die Bearbeitung von Senderinformationen ist aktiviert. Drücke lange, um den Bearbeitungsmodus aufzurufen.
+ Sender bearbeiten
+ Allgemein
+ Diese Anwendung ist Open Source
+ Lizenziert unter der MIT License
+ Links
+ Radiosender in einer M3U-Playlistdatei sichern, die in andere Player importiert werden können.
+ M3U-Playlist exportieren
+ Wartung
+ Radiosender in einer PLS-Playlistdatei sichern, die in andere Player importiert werden können.
+ PLS-Playlist exportieren
+ Die gesamte Sammlung von Radiosendern einschließlich der Bilder im Gerätespeicher sichern.
+ Stationen exportieren
+ Sammlung von Radiosendern aus der Sicherung wiederherstellen. Vorhandene Sender werden ersetzt.
+ Stationen importieren
+ Dunkles Design
+ Systemstandard
+ Helles Design
+ Aktuell:
+ App-Design
+ Die neueste Version aller Senderbilder herunterladen.
+ Senderbilder aktualisieren
+
+
+ Verknüpfung für Wiedergabe des letzten Senders deaktiviert.
+ Letzten Sender wiedergeben
+ Letzter Sender
+
+ Bildauswahl fehlgeschlagen.
+
+ wurde erfolgreich gesichert.
+ Die Verbindung konnte nicht aufgebaut oder wiederhergestellt werden.
+ In die Zwischenablage kopiert
+ Fehler beim Herunterladen
+ Die Wiedergabe konnte nicht gestartet oder neu gestartet werden
+ Bitte installiere einen Dateimanager
+ Die Vorschauwiedergabe ist nicht möglich.
+ Die Vorschauwiedergabe wurde gestartet.
+ Stationen wurden erfolgreich wiederhergestellt.
+ Radiosender als M3U gespeichert…
+ Radiosender als PLS gespeichert…
+ Sender auf Startbildschirm platzieren
+ Sender konnte nicht auf dem Startbildschirm platziert werden. Das Gerät unterbindet das Platzieren von Verknüpfungen.
+ Bitte zuerst die Wiedergabe starten
+ Dieser Sender ist ein Duplikat
+ Dieser Sender existiert nicht
+ Alle Sender werden aktualisiert
+ Alle Senderbilder werden aktualisiert
+
+ Zeigen
+ ist verfügbar!
+
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000..11caad8
--- /dev/null
+++ b/app/src/main/res/values-el/strings.xml
@@ -0,0 +1,112 @@
+
+
+
+
+ Εικονίδιο εφαρμογής που απεικονίζει παλιό ραδιόφωνο
+ Αναπαραγωγή/Διακοπή
+ Σταθμός με αστέρι
+ Αλλαγή εικόνας σταθμού
+ Εικόνα σταθμού
+ Αντιγραφή πληροφοριών αναπαραγωγής τώρα
+ Επόμενη καταχώρηση μεταδεδομένων αναπαραγωγής τώρα
+ Προηγούμενη καταχώρηση μεταδεδομένων αναπαραγωγής τώρα
+ Ακύρωση χρονοδιακόπτη ύπνου
+ Χρόνος που απομένει στο χρονοδιακόπτη ύπνου
+ Έναρξη χρονοδιακόπτη ύπνου
+ Αναπαραγωγή/Παύση
+ Εικόνα σταθμού
+
+ Όνομα σταθμού
+ Διεύθυνση ροής
+ Προέκυψε ένα σφάλμα
+ Συνδεθείτε στο διαδίκτυο.
+ Εκτός σύνδεσης
+ Προσθήκη
+ Όνομα ή διεύθυνση ροής
+ Δεν βρέθηκαν αποτελέσματα.
+ Αναζήτηση Σταθμού
+ Ακύρωση
+ ΟΚ
+ Εμφάνιση λεπτομερειών
+ Δεν υπάρχουν διαθέσιμες λεπτομέρειες
+ Αφαίρεση αυτού του σταθμού;
+ Να κατεβεί η τελευταία έκδοση όλων των εικόνων σταθμών;
+ Ναι
+ Αφαίρεση
+ Ενημέρωση
+
+ Ρυθμίσεις
+
+ Έλεγχοι Αναπαραγωγής
+ Αναπαραγωγή
+ Διακοπή
+ Προηγούμενο
+ Επόμενο
+
+ Ας ξεκινήσουμε
+
+ Παίζεται τώρα
+ Σύνδεσμος ροής
+
+ Για προχωρημένους
+ Έκδοση
+ Έκδοση Εφαρμογής
+ Εισαγωγή & Εξαγωγή
+ Χρησιμοποιείς το προεπιλεγμένο μέγεθος buffer για την αναπαραγωγή μιας ροής ραδιοφώνου.
+ Μεγαλύτερο buffer για την αναπαραγωγή μιας ροής ραδιοφώνου ενεργοποιημένο. Μπορεί να χρειαστεί περισσότερος χρόνος για να ξεκινήσει η αναπαραγωγή.
+ Χρήση Μεγαλύτερου Buffer
+ Επεξεργασία συνδέσμων ροής απενεργοποιημένη.
+ Επεξεργασία συνδέσμων ροής ενεργοποιημένη. Βεβαιωθείτε ότι εισάγετε μια σωστή διεύθυνση ροής.
+ Επεξεργασία Συνδέσμων Ροής
+ Επεξεργασία πληροφοριών σταθμού απενεργοποιημένη.
+ Επεξεργασία πληροφοριών σταθμού ενεργοποιημένη. Πατήστε παρατεταμένα για να εισέλθετε σε λειτουργία επεξεργασίας.
+ Επεξεργασία Σταθμών
+ Γενικές ρυθμίσεις
+ Αυτή η εφαρμογή είναι ανοικτού κώδικα
+ Αδειοδότηση με άδεια MIT
+ Σύνδεσμοι
+ Αποθηκεύστε τους ραδιοφωνικούς σας σταθμούς σε ένα αρχείο λίστας αναπαραγωγής M3U που μπορεί να εισαχθεί σε άλλους αναπαραγωγούς.
+ Εξαγωγή M3U
+ Συντήρηση
+ Αποθηκεύστε τους ραδιοφωνικούς σας σταθμούς σε ένα αρχείο λίστας αναπαραγωγής PLS που μπορεί να εισαχθεί σε άλλους αναπαραγωγούς.
+ Εξαγωγή PLS
+ Αποθήκευση ολόκληρης της συλλογής ραδιοφωνικών σταθμών, συμπεριλαμβανομένων των εικόνων στον αποθηκευτικό χώρο.
+ Εξαγωγή Σταθμών
+ Επαναφορά της συλλογής ραδιοφωνικών σταθμών από backup. Θα αντικατασταθούν οι υπάρχοντες σταθμοί.
+ Επαναφορά Σταθμών
+ Σκοτεινή λειτουργία
+ Ίδιο με τη συσκευή
+ Φωτεινή λειτουργία
+ Τρέχον θέμα:
+ Θέμα Εφαρμογής
+ Κατεβάστε την τελευταία έκδοση των εικόνων όλων των σταθμών.
+ Ενημέρωση Εικόνων Σταθμών
+
+
+ Η συντόμευση για την αναπαραγωγή του τελευταίου σταθμού έχει απενεργοποιηθεί.
+ Αναπαραγωγή τελευταίου σταθμού
+ Τελευταίος σταθμός
+
+ Η επιλογή εικόνας απέτυχε.
+
+ έχει δημιουργηθεί επιτυχώς το αντίγραφο ασφαλείας.
+ Αντιγράφηκε στο πρόχειρο.
+ Σφάλμα λήψης
+ Αδυναμία εκκίνησης ή επανεκκίνησης της αναπαραγωγής.
+ Παρακαλώ εγκαταστήστε ένα πρόγραμμα διαχείρισης αρχείων.
+ Δεν είναι δυνατή η προεπισκόπηση αναπαραγωγής.
+ Η αναπαραγωγή προεπισκόπησης ξεκίνησε.
+ Οι σταθμοί έχουν αποκατασταθεί με επιτυχία.
+ Αποθήκευση ραδιοφωνικών σταθμών ως M3U…
+ Αποθήκευση ραδιοφωνικών σταθμών ως PLS…
+ Δημιουργήθηκε συντόμευση.
+ Η συντόμευση δεν δημιουργήθηκε. Αυτή η συσκευή δεν επιτρέπει τη δημιουργία συντομεύσεων.
+ Παρακαλώ αρχίστε πρώτα την αναπαραγωγή.
+ Αυτός ο σταθμός είναι διπλότυπος.
+ Ο σταθμός δεν είναι έγκυρος.
+ Ενημέρωση συλλογής σταθμών.
+ Ενημέρωση εικόνων σταθμών.
+
+ Εμφάνισε
+ είναι διαθέσιμη!
+
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml
new file mode 100644
index 0000000..c826e95
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ @android:color/system_neutral1_100
+
+
+ @android:color/system_neutral1_100
+ @android:color/system_neutral2_200
+ @android:color/system_accent1_100
+ @android:color/system_neutral2_200
+ @android:color/system_accent2_600
+
+
+ @android:color/system_neutral1_900
+ @android:color/system_accent2_600
+ @android:color/system_neutral2_700
+ #FFF2B8B5
+ #FF601410
+ @android:color/system_accent1_100
+ @android:color/system_accent1_800
+ @android:color/system_neutral2_800
+ @android:color/system_accent2_700
+
+
+ @android:color/system_accent1_800
+ @android:color/system_accent1_100
+ @android:color/system_accent1_600
+ @android:color/system_neutral2_800
+ @android:color/system_neutral1_50
+ @android:color/system_neutral1_50
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night-v31/styles.xml b/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..d4aa482
--- /dev/null
+++ b/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..a004b7e
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ #FFFFFFFF
+
+
+ #FFFFFFFF
+ #FFC5C6D0
+ #FFDAE2FF
+ #FFC5C6D0
+ #FF585E71
+
+
+ #FF1B1B1F
+ #FF585E71
+ #FF49454f
+ #FFF2B8B5
+ #FF601410
+ #FFDAE2FF
+ #FF182E60
+ #FF1B1B1F
+ #FF414659
+
+
+ #FF182E60
+ #FFDAE2FF
+ #FF4A5F97
+ #FF2E3038
+ #FFFFFFFF
+ #FFFFFFFF
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..e4e5528
--- /dev/null
+++ b/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000..9ffe9f2
--- /dev/null
+++ b/app/src/main/res/values-nl/strings.xml
@@ -0,0 +1,102 @@
+
+
+
+
+ App icoon die een oude radio afbeeld
+ Afspelen/stoppen
+ Zender met ster
+ Wijzig zenderafbeelding
+ Zenderafbeelding
+ Kopieer de \'Nu spelende\' informatie
+ Volgende \'Nu spelende\' metadata invoer
+ Vorige \'Nu spelende\' metadata invoer
+ Annuleer slaapstand timer
+ Resterende tijd op slaapstand timer
+ Start slaapstand timer
+ Afspelen/pauzeren
+ Zenderafbeelding
+
+ Zendernaam
+ Stream adres
+ Er is een fout opgetreden
+ Verbind met het Internet.
+ Offline
+ Toevoegen
+ Naam of stream adres
+ Geen resultaten gevonden.
+ Vind Zender
+ Annuleer
+ OK
+ Toon details
+ Geen details beschikbaar
+ Verwijder deze zender?
+ Laatste versie van alle zenderafbeeldingen downloaden?
+ Ja
+ Verwijder
+ Bijwerken
+
+ Instellingen
+
+ Afspeelbediening
+ Afspelen
+ Stoppen
+ Vorige
+ Volgende
+
+ Aan de slag
+
+ Nu aan het afspelen
+ Link naar de stream
+
+ Geavanceerd
+ Versie
+ App Versie
+ Importeren & Exporteren
+ De standaard buffergrootte wordt gebruikt voor het afspelen van een radiostream.
+ Grotere buffer voor het afspelen van een radiostream ingeschakeld. Het kan langer duren voordat het afspelen start.
+ Grotere Buffer Gebruiken
+ Bewerken van streaming links uitgeschakeld.
+ Bewerken van streaming links ingeschakeld. Zorg ervoor dat je een correct stream adres invoert.
+ Streaming Links Bewerken
+ Bewerken van zendergegevens uitgeschakeld.
+ Bewerken van zendergegevens ingeschakeld. Gebruik lang indrukken om de bewerkingsmodus te starten.
+ Zenders Bewerken
+ Algemeen
+ Sla je radiozenders op in een M3U afspeellijstbestand dat in andere spelers kan worden geïmporteerd.
+ Exporteer M3U
+ Onderhoud
+ Sla de gehele collectie radiozenders, inclusief afbeeldingen, in de apparaatopslag op.
+ Exporteer Zender
+ Herstel verzameling van radiozenders van back-up. Bestaande zenders zullen worden vervangen.
+ Zenders Herstellen
+ Donkere modus
+ Hetzelfde als apparaat
+ Lichte modus
+ Huidig thema:
+ App Thema
+ Download de laatste versie van alle zenderafbeeldingen.
+ Update Zenderafbeeldingen
+
+
+ Snelkoppeling voor het afspelen van de laatste zender uitgeschakeld.
+ Laatste zender afspelen
+ Laatste zender
+
+ Afbeeldingsselectie mislukt.
+
+ back-up is succesvol gemaakt.
+ Gekopieerd naar het klembord.
+ Download fout
+ Installeer alstublieft een bestandsbeheer applicatie.
+ Zenders zijn succesvol hersteld.
+ Radiozenders opslaan als M3U…
+ Snelkoppeling gemaakt.
+ Snelkoppeling niet gemaakt. Dit apparaat staat het niet toe snelkoppelingen aan te maken.
+ Start alstublieft eerst het afspelen.
+ Zender is ongeldig.
+ Verzameling van zenders bijwerken.
+ Zenderafbeeldingen bijwerken.
+
+ Weergeven
+ is beschikbaar!
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000..9337158
--- /dev/null
+++ b/app/src/main/res/values-pl/strings.xml
@@ -0,0 +1,104 @@
+
+
+
+
+ Ikona aplikacji przedstawiająca stare radio
+ Odtwórz/zatrzymaj
+ Polubione stacje
+ Zmień ikonę stacji
+ Zdjęcie stacji
+ Skopiuj teraz odtwarzane informacje
+ Następne odtwarzanie wpisu metadanych
+ Poprzedni odtwarzany wpis metadanych
+ Anuluj wyłącznik czasowy
+ Pozostały czas do wyłączenia stacji
+ Włącz wyłącznik czasowy
+ Odtwórz/zatrzymaj
+ Ikona stacji
+
+ Nazwa stacji
+ Adres strumienia
+ Wystąpił błąd
+ Połącz się z internetem.
+ Offline
+ Dodaj
+ Nazwa lub adres strumienia
+ Brak wyników.
+ Znajdź stację
+ Anuluj
+ OK
+ Pokaż szczegóły
+ Brak dostępnych szczegółów
+ Usunąć stację?
+ Pobrać najnowszą wersję obrazów wszystkich stacji?
+ Tak
+ Usuń
+ Aktualizuj
+
+ Ustawienia
+
+ Sterowanie odtwarzaniem
+ Odtwarzaj
+ Zatrzymaj
+ Poprzedni
+ Następny
+
+ Zaczynamy
+
+ Obecnie odtwarzane
+ URL strumienia
+
+ Zaawansowane
+ Wersja
+ Wersja aplikacji
+ Import & Eksport
+ Używanie domyślnego rozmiaru bufora do odtwarzania strumienia radiowego.
+ Większy bufor do odtwarzania strumienia radiowego włączony. Rozpoczęcie odtwarzania może trwać dłużej.
+ Użyj większego bufora
+ Edycja linków strumieniowych wyłączona.
+ Włączona edycja linków strumieniowych. Upewnij się, że podałeś poprawny adres strumienia.
+ Edytuj linki do streamingu
+ Edycja informacji o stacji wyłączona.
+ Włączona edycja informacji o stacji. Użyj długiego naciśnięcia, aby przejść do trybu edycji.
+ Edytuj stację
+ Ogólne
+ Zapisz swoje stacje radiowe w pliku listy odtwarzania M3U, który można zaimportować do innych odtwarzaczy.
+ Eksportuj M3U
+ Zarządzanie
+ Zapisz do pamięci urządzenia całą kolekcję stacji radiowych, w tym obrazy.
+ Eksportuj stacje
+ Przywróć kolekcję stacji radiowych z kopii zapasowej. Istniejące stacje zostaną zastąpione.
+ Przywróć stacje
+ Tryb ciemny
+ Taki sam jak na urządzeniu
+ Tryb jasny
+ Aktualny motyw:
+ Motyw aplikacji
+ Pobierz najnowszą wersję wszystkich obrazów stacji w swojej kolekcji.
+ Aktualizuj zdjęcia stacji
+
+
+ Skrót do odtwarzania ostatniej stacji jest wyłączony.
+ Odtwórz ostatnią stację
+ Ostatnia stacja
+
+ Wybór obrazu nie powiódł się.
+
+ kopia zapasowa została wykonana.
+ Skopiowano do schowka.
+ Błąd pobierania
+ Nie można ponownie uruchomić odtwarzania.
+ Proszę zainstalować menedżer plików.
+ Kopia zapasowa została pomyślnie przywrócona.
+ Zapisywanie stacji radiowych jako M3U…
+ Skrót utworzony.
+ Skrót nie został utworzony. To urządzenie nie pozwala na tworzenie skrótów.
+ Najpierw zacznij odtwarzanie.
+ Ta stacja jest duplikatem.
+ Stacja jest nieprawidłowa.
+ Aktualizacja zbioru stacji.
+ Aktualizowanie obrazów stacji.
+
+ Wyświetl
+ jest dostępna!
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..ed032ee
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,112 @@
+
+
+
+
+ Значок приложения с изображением старого радио
+ Играть/остановить
+ Избранная станция
+ Изменить изображение станции
+ Изображение станции
+ Копирование информации о текущем воспроизведении
+ Следующая запись метаданных текущего воспроизведения
+ Предыдущая запись метаданных о текущем воспроизведении
+ Отменить таймер сна
+ Время, оставшееся в таймере сна
+ Запустить таймер сна
+ Играть/пауза
+ Изображение станции
+
+ Имя станции
+ Адрес потока
+ Произошла ошибка
+ Подключитесь к Интернету.
+ Офлайн
+ Добавить
+ Имя или адрес потока
+ Ничего не найдено.
+ Найти станцию
+ Отмена
+ ОК
+ Показать детали
+ Детали недоступны
+ Удалить эту станцию?
+ Скачать последнюю версию всех изображений станций?
+ Да
+ Удалить
+ Обновить
+
+ Настройки
+
+ Управление воспроизведением
+ Воспроизвести
+ Остановить
+ Предыдущий
+ Следующий
+
+ Начать
+
+ Сейчас играет
+ Адрес потока
+
+ Продвинутые
+ Версия
+ Версия приложения
+ Импорт & Экспорт
+ Использование размера буфера по умолчанию для воспроизведения радиопоток.
+ Включен увеличенный буфер для воспроизведения радиопотока. Для начала воспроизведения может потребоваться больше времени.
+ Использование буфера большего размера
+ Редактирование ссылок отключено.
+ Редактирование ссылок включено. Убедитесь, что вы ввели правильный адрес станции.
+ Редактировать ссылку
+ Редактирование информации о станции отключено.
+ Редактирование информации о станции включено. Используйте долгое нажатие для редактирования.
+ Редактировать станции
+ Общие
+ Это приложение с открытым исходным кодом
+ Лицензировано под лицензией MIT
+ Ссылки
+ Сохраните ваши радиостанции в файл плейлиста M3U, который можно импортировать в другие проигрыватели.
+ Экспорт M3U
+ Обслуживание
+ Сохраняйте радиостанции в файле плейлиста PLS, который можно импортировать в другие плееры.
+ Экспорт PLS
+ Сохраните всю коллекцию радиостанций, включая изображения на устройство.
+ Экспорт станции
+ Восстановление коллекции радиостанций из резервной копии. Существующие станции будут заменены.
+ Восстановить станции
+ Тёмная
+ Как на устройстве
+ Светлая
+ Текущая тема:
+ Тема приложения
+ Скачать последнюю версию всех изображений станций.
+ Обновить изображения станций
+
+
+ Ярлык для воспроизведения последней станции отключён.
+ Воспроизвести последнюю станцию
+ Последняя станция
+
+ Не удалось выбрать изображение.
+
+ резервная копия создана.
+ Скопировано в буфер обмена.
+ Ошибка загрузки
+ Невозможно запустить или перезапустить воспроизведение.
+ Пожалуйста, установите файловый менеджер.
+ Невозможно воспроизведение предварительного просмотра.
+ Было запущено воспроизведение предварительного просмотра.
+ Станции были успешно восстановлены.
+ Сохранение радиостанций как M3U…
+ Сохранение радиостанций в формате PLS...
+ Ярлык создан.
+ Ярлык не создан. Устройство не позволяет создавать ярлыки.
+ Пожалуйста, запустите сперва воспроизведение.
+ Эта станция является дубликатом.
+ Станция не действительна.
+ Обновление коллекции станций.
+ Обновление изображений станций.
+
+ Показать
+ доступно!
+
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000..bc4879f
--- /dev/null
+++ b/app/src/main/res/values-uk/strings.xml
@@ -0,0 +1,113 @@
+
+
+
+
+ Значок застосунку із зображенням старого радіо
+ Відтворити/зупинити
+ Обрана станція
+ Змінити зображення станції
+ Зображення станції
+ Скопіювати відомості про поточне відтворення
+ Наступний елемент метаданих поточного відтворення
+ Попередній елемент метаданих поточного відтворення
+ Скасувати таймер сну
+ Час, що залишився в таймері сну
+ Запустити таймер сну
+ Відтворити/призупинити
+ Зображення станції
+
+ Назва станції
+ Адреса трансляції
+ Виникла помилка
+ Підключіться до інтернету.
+ Поза мережею
+ Додати
+ Назва або адреса трансляції
+ Нічого не знайдено.
+ Знайти станцію
+ Скасувати
+ Добре
+ Показати подробиці
+ Подробиці недоступні
+ Видалити цю станцію?
+ Завантажити останню версію всіх зображень станцій?
+ Так
+ Видалити
+ Оновити
+
+ Налаштування
+
+ Керування відтворенням
+ Відтворити
+ Зупинити
+ Попередня
+ Наступна
+
+ Початок роботи
+
+ Зараз грає
+ URL трансляції
+
+ Додатково
+ Версія
+ Версія застосунку
+ Імпорт / Експорт
+ Використовується типовий розмір буферу для відтворення радіопотоку.
+ Увімкнено буфер більшого розміру для програвання радіопотоку. Може знадобитися більше часу для запуску відтворення.
+ Використовувати буфер більшого розміру
+ Редагування посилань на трансляції вимкнено.
+ Увімкнено редагування посилань на трансляції. Переконайтеся, що ви вказали правильну адресу трансляції.
+ Редагувати посилання на трансляції
+ Редагування інформації про станцію вимкнено.
+ Редагування інформації про станцію увімкнено. Використовуйте тривале натискання для переходу у режим редагування.
+ Редагувати станції
+ Загальні
+ Цей застосунок має відкритий початковий код
+ На умовах ліцензії MIT
+ Посилання
+ Збережіть свої радіостанції у файлі списку відтворення M3U, який можна імпортувати в інші програвачі.
+ Експорт M3U
+ Обслуговування
+ Збережіть свої радіостанції у файлі списку відтворення PLS, який можна імпортувати в інші програвачі.
+ Експорт PLS
+ Зберегти всю колекцію радіостанцій, включаючи зображення до пам\'яті пристрою.
+ Експорт станції
+ Відновлення колекції радіостанцій із резервної копії. Наявні станції буде замінено.
+ Відновити станції
+ Темний режим
+ Як на пристрої
+ Світлий режим
+ Поточна тема:
+ Тема застосунку
+ Завантажити останню версію всіх зображень станцій.
+ Оновити зображення станцій
+
+
+ Ярлик для відтворення останньої станції вимкнено.
+ Відтворити останню станцію
+ Остання станція
+
+ Не вдалося обрати зображення.
+
+ було успішно зарезервовано.
+ Неможливо встановити або відновити з\'єднання.
+ Скопійовано до буфера обміну.
+ Помилка завантаження
+ Не вдалося почати або перезапустити відтворення.
+ Будь ласка, встановіть менеджер файлів.
+ Попереднє відтворення неможливе.
+ Розпочалося попереднє відтворення.
+ Станції були успішно відновлені.
+ Збереження радіостанцій як М3U…
+ Збереження радіостанцій як PLS…
+ Посилання створено.
+ Ярлик не створено. Цей пристрій не дозволяє створювати ярлики.
+ Будь ласка, спочатку почніть відтворення.
+ Ця станція є копією.
+ Станція недійсна.
+ Оновлення колекції станцій.
+ Оновлення зображень станцій.
+
+ Показати
+ доступне!
+
diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml
new file mode 100644
index 0000000..99484e9
--- /dev/null
+++ b/app/src/main/res/values-v27/styles.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml
new file mode 100644
index 0000000..d032ced
--- /dev/null
+++ b/app/src/main/res/values-v31/colors.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ @android:color/system_neutral1_900
+
+
+ @android:color/system_neutral2_900
+ @android:color/system_neutral2_700
+ @android:color/system_accent1_600
+ @android:color/system_neutral2_700
+ @android:color/system_accent2_200
+
+
+ @android:color/system_neutral2_10
+ @android:color/system_accent2_200
+ @android:color/system_neutral2_100
+ #FFB3261E
+ @android:color/system_accent1_0
+ @android:color/system_accent1_600
+ @android:color/system_accent1_0
+ @android:color/system_neutral2_10
+ @android:color/system_accent1_100
+
+
+ @android:color/system_accent1_600
+ @android:color/system_accent1_200
+ @android:color/system_neutral2_800
+ @android:color/system_neutral1_50
+ @android:color/system_neutral1_50
+
+
+ @android:color/system_accent1_0
+ @android:color/system_neutral1_100
+ @android:color/system_neutral1_300
+ @android:color/system_neutral1_600
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-v31/styles.xml b/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..b00aac1
--- /dev/null
+++ b/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..5232394
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ #FF313131
+
+
+ #FF595959
+ #FF45464F
+ #FF495D92
+ #FF45464F
+ #FFC0C6DD
+
+
+ #FFFEFBFF
+ #FFC0C6DD
+ #FFE7E0EC
+ #FFB3261E
+ #FFFFFFFF
+ #FF495D92
+ #FFFFFFFF
+ #FFFEFBFF
+ #FFDAE2FF
+
+
+ #FF495D92
+ #FFDAE2FF
+ #FF2E3038
+ #FFF4EFF4
+ #FFF4EFF4
+
+
+ #FFFFFFFF
+ #FFE6E1E5
+ #FF7D7D7D
+ #FF595959
+ #D9313033
+
+ #FF1D3E66
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..f3f7814
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #2A5298
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..c225c96
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,139 @@
+
+
+
+ \"Sweet\"
+
+
+ App icon depicting an old radio
+ Play/stop
+ Starred station
+ Change station image
+ Station image
+ Copy the now playing information
+ Next now playing metadata entry
+ Previous now playing metadata entry
+ Cancel sleep timer
+ Time remaining on sleep timer
+ Start sleep timer
+ Play/pause
+ Station image
+
+
+ Add Station
+ Station name
+ Stream address
+ An error occurred
+ Connect to the Internet.
+ Offline
+ Add
+ Name or stream address
+ No results found.
+ Find Station
+ Cancel
+ OK
+ Show details
+ No details available
+ Replace current collection of radio stations with the radio station from backup?
+ Remove this station?
+ Download latest version of all station images?
+ Yes
+ Remove
+ Update
+
+
+ Settings
+
+
+ Playback Controls
+ Play
+ Stop
+ Previous
+ Next
+
+
+ Immerse yourself in the sound of your choice!
+ Get started
+
+
+ Currently playing
+ Streaming link
+
+
+ Advanced
+ Version
+ App Version
+ Import & Export
+ Using the default buffer size for playing a radio stream.
+ Larger buffer for playing a radio stream enabled. It may take longer for the playback to start.
+ Use Larger Buffer
+ Editing of streaming links disabled.
+ Editing of streaming links enabled. Make sure to enter a correct stream address.
+ Edit Streaming Links
+ Editing of station information disabled.
+ Editing of station information enabled. Use long press to enter edit mode.
+ Edit Stations
+ General
+ GitHub
+ github.com/michatec/Radio
+ This application is open source
+ Licensed under the MIT License
+ Links
+ Save your radio stations to an M3U playlist file that can be imported into other players.
+ Export M3U
+ Maintenance
+ Save your radio stations to an PLS playlist file that can be imported into other players.
+ Export PLS
+ Save entire collection of radio stations including images to device storage.
+ Export Stations
+ Restore collection of radio stations from backup. Existing stations will be replaced.
+ Restore Stations
+ Dark mode
+ Same as device
+ Light mode
+ Current theme:
+ App Theme
+ Download latest version of all station images.
+ Update Station Images
+
+
+ 00:00
+ Future - Mask Off
+ bigFM Deutschland
+ https://streams.bigfm.de/bigfm-deutschland-128-mp3
+ MP3 | 128kbps
+
+
+ Shortcut for playback of last station disabled.
+ Play last station
+ Last station
+
+
+ Image selection failed.
+
+
+ has been successfully backed up.
+ The connection could not be established or restored.
+ Copied to clipboard.
+ Download error
+ Unable to start or restart playback.
+ Please install a file manager.
+ Preview playback not possible.
+ Preview playback was started.
+ Stations has been successfully restored.
+ Saving radio stations as M3U…
+ Saving radio stations as PLS…
+ Shortcut created.
+ Shortcut not created. This device does not allow the creation of shortcuts.
+ Please start playback first.
+ This station is a duplicate.
+ Station is not valid.
+ Updating collection of stations.
+ Updating station images.
+
+
+ Show
+ is available!
+ https://github.com/michatec/Radio/releases/latest
+ https://api.github.com/repos/michatec/Radio/releases/latest
+ Radio
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..903b6a0
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/allowed_media_browser_callers.xml b/app/src/main/res/xml/allowed_media_browser_callers.xml
new file mode 100644
index 0000000..483dfac
--- /dev/null
+++ b/app/src/main/res/xml/allowed_media_browser_callers.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
+
+
+ 70:81:1a:3e:ac:fd:2e:83:e1:8d:a9:bf:ed:e5:2d:f1:6c:e9:1f:2e:69:a4:4d:21:f1:8a:b6:69:91:13:07:71
+
+
+ fd:b0:0c:43:db:de:8b:51:cb:31:2a:a8:1d:3b:5f:a1:77:13:ad:b9:4b:28:f5:98:d7:7f:8e:b8:9d:ac:ee:df
+
+
+
+
+
+ 69:d0:72:16:9a:2c:6b:2f:5a:cc:59:0c:e4:33:a1:1a:c3:df:55:1a:df:ee:5d:5f:63:c0:83:b7:22:76:2e:19
+
+
+ 85:cd:59:73:54:1b:e6:f4:77:d8:47:a0:bc:c6:aa:25:27:68:4b:81:9c:d5:96:85:29:66:4c:b0:71:57:b6:fe
+
+
+
+
+
+ 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
+
+
+
+
+
+ 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
+
+
+ f0:fd:6c:5b:41:0f:25:cb:25:c3:b5:33:46:c8:97:2f:ae:30:f8:ee:74:11:df:91:04:80:ad:6b:2d:60:db:83
+
+
+
+
+
+ 7b:2a:46:f8:1e:cd:01:c0:b5:c7:bd:c9:9a:ca:6a:85:5b:f7:64:26:f5:4e:9a:d3:29:80:b1:da:41:76:63:e9
+
+
+
+
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..0f739ff
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..de7b18d
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..1e197c9
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..44da2f0
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..f18e1f0
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 0000000..4e72fb4
--- /dev/null
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..3d2911a
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..52fa25e
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,11 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+plugins {
+ id 'com.android.application' version '8.6.1' apply false
+ id 'com.android.library' version '8.6.1' apply false
+ id 'org.jetbrains.kotlin.android' version "1.9.21" apply false
+}
+
+tasks.register('clean', Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..b5a05b5
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,17 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# enables androidx repo
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..f6b961f
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..cd8dc18
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jan 28 14:15:43 CET 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..cccdd3d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..f955316
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..bd5b3c3
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+include ':app'