Initial commit

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

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
*.iml
.gradle
.DS_Store
/local.properties
/.idea
/build
/captures

23
LICENSE.md Normal file
View File

@@ -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.

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
<div align="center">
### About Radio
**Radio is an application with a minimalist approach to listening to radio over the Internet.** <br>
**Radio only offers a very basic search option, and it imports audio streaming links when you tap them in a web browser.** <br>
**Pull request are welcome at any time.**<br>
**Radio is free software. It is released under the [MIT open source license](https://opensource.org/licenses/MIT).**
</div>
----------------------------------------
<details>
<summary>⚙️ Install Radio</summary>
<br>
<a href="https://github.com/michatec/Radio/releases/latest"><img src="https://user-images.githubusercontent.com/15986930/229208526-e5a63be5-0d0b-48ab-a222-9f2f2faf0ee4.png" height="80px"></a>
</details>
----------------------------------------
<details>
<summary>💡 Frequent Questions</summary>
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.
</details>
----------------------------------------
<details>
<summary>🔊 Supported formats</summary>
<br>
| Supported formats | 🔊 |
| ------------------ | -- |
| AAC | ✅ |
| AAC+ | ✅ |
| FLAC | ✅ |
| HLS (M3U8) | ✅ |
| M3U | ✅ |
| MP3 | ✅ |
| OGG (Vorbis) | ✅ |
| OPUS | ✅ |
| PLS | ✅ |
</details>
----------------------------------------
<details>
<summary>📜️ Credit</summary>
Base app Michatec.
</details>
<div align="right">
<table><td>
<a href="#start-of-content">↥ Scroll to top</a>
</td></table>
</div>

3
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/build
/release
/debug

68
app/build.gradle Normal file
View File

@@ -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'
}

25
app/proguard-rules.pro vendored Normal file
View File

@@ -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 { *; }

View File

@@ -0,0 +1,170 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".Radio"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:targetApi="tiramisu">
<!-- ANDROID AUTO SUPPORT -->
<!-- https://developer.android.com/training/auto/audio/ -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher" />
<!-- Main activity for radio station playback on phone -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize"
android:windowSoftInputMode="adjustPan"
android:exported="true">
<!-- react to main intents -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- react to be recognized as a music player -->
<intent-filter>
<action android:name="android.intent.action.MUSIC_PLAYER" />
<category android:name="android.intent.category.CATEGORY_APP_MUSIC" />
</intent-filter>
<!-- react to voice searches, like "Play Security Now" -->
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- react to playlist-links based on file extension -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
<data android:pathPattern=".*\\.m3u" />
<data android:pathPattern=".*\\.m3u8" />
<data android:pathPattern=".*\\.pls" />
</intent-filter>
<!-- react to playlist-links based on mimetype -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
<data android:mimeType="audio/x-scpls" />
<data android:mimeType="audio/mpegurl" />
<data android:mimeType="audio/x-mpegurl" />
<data android:mimeType="application/pls+xml" />
<data android:mimeType="application/x-mpegurl" />
<data android:mimeType="application/vnd.apple.mpegurl" />
<data android:mimeType="application/vnd.apple.mpegurl.audio" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
<!-- react to hls playlist-links based on mimetype -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="audio/x-scpls" />
<data android:mimeType="audio/mpegurl" />
<data android:mimeType="audio/x-mpegurl" />
<data android:mimeType="application/pls+xml" />
<data android:mimeType="application/x-mpegurl" />
<data android:mimeType="application/vnd.apple.mpegurl" />
<data android:mimeType="application/vnd.apple.mpegurl.audio" />
</intent-filter>
<!-- react to "start player service" intents -->
<intent-filter>
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
</intent-filter>
<!-- App Shortcuts -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Player Service -->
<service
android:name=".PlayerService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
</intent-filter>
</service>
<!-- handles completed downloads -->
<receiver
android:name=".helpers.DownloadFinishedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<!-- handles media buttons -->
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- file provider -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -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"
}

View File

@@ -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
*/
}

View File

@@ -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<MediaController>
private lateinit var pickSingleMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>
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<SessionResult>? =
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<SessionResult>? = controller?.requestMetadataHistory()
resultFuture?.addListener(Runnable {
val metadata: ArrayList<String>? = 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<Station> = 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
*/
}

View File

@@ -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<String>
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<Collection> =
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<MediaItem>
): ListenableFuture<List<MediaItem>> {
val updatedMediaItems: List<MediaItem> =
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<LibraryResult<Void>> {
val children: List<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val children: List<MediaItem> = CollectionHelper.getChildren(this@PlayerService, collection)
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
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<LibraryResult<MediaItem>> {
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<SessionResult> {
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<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
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<CommandButton> = 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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<RecyclerView.ViewHolder>(), 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<NetworkHelper.ContentType> =
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<Any>
) {
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
*/
}

View File

@@ -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<Collection> = MutableLiveData<Collection>()
val collectionSizeLiveData: MutableLiveData<Int> = MutableLiveData<Int>()
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
}
}
}

View File

@@ -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<Station> = 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<Station> = mutableListOf()
stations.forEach { stationsCopy.add(it.deepCopy()) }
return Collection(
version = version,
stations = stationsCopy,
modificationDate = modificationDate
)
}
}

View File

@@ -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<String> = 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
)
}
}

View File

@@ -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<Station>,
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
}
}

View File

@@ -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()
}
}

View File

@@ -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<RadioBrowserResult>) {
if (results.isNotEmpty()) {
val stationList: List<Station> = results.map {it.toStation()}
searchResultAdapter.searchResults = stationList
searchResultAdapter.notifyDataSetChanged()
resetLayout(clearAdapter = false)
} else {
showNoResultsError()
}
}
/* Overrides onDirectInputCheck from DirectInputCheck */
override fun onDirectInputCheck(stationList: MutableList<Station>) {
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
}
}

View File

@@ -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()
}
}

View File

@@ -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<Long> - useful for preventing concurrent modification issues */
fun ArrayList<Long>.copy(): ArrayList<Long> {
val copy: ArrayList<Long> = ArrayList()
this.forEach { copy.add(it) }
return copy
}

View File

@@ -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<SessionResult> {
return sendCustomCommand(
SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY),
Bundle.EMPTY
)
}
/* Request sleep timer remaining */
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
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))
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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<Station> = collection.stations.toMutableList()
// add station
updatedStationList.add(newStation)
updatedCollection.stations = updatedStationList
// sort and save collection
updatedCollection = sortCollection(updatedCollection)
saveCollection(context, updatedCollection, false)
// download station image
DownloadHelper.updateStationImage(context, newStation)
// return updated collection
return updatedCollection
}
}
/* Sets station image - determines station by remote image file location */
fun setStationImageWithRemoteLocation(
context: Context,
collection: Collection,
tempImageFileUri: String,
remoteFileLocation: String,
imageManuallySet: Boolean = false
): Collection {
collection.stations.forEach { station ->
// compare image location protocol-agnostic (= without http / https)
if (station.remoteImageLocation.substringAfter(":") == remoteFileLocation.substringAfter(
":"
)
) {
station.smallImage = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri.toUri(),
Keys.SIZE_STATION_IMAGE_CARD,
Keys.STATION_IMAGE_FILE
).toString()
station.image = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri.toUri(),
Keys.SIZE_STATION_IMAGE_MAXIMUM,
Keys.STATION_IMAGE_FILE
).toString()
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri.toUri())
station.imageManuallySet = imageManuallySet
}
}
// save and return collection
saveCollection(context, collection)
return collection
}
/* Sets station image - determines station by remote image file location */
fun setStationImageWithStationUuid(
context: Context,
collection: Collection,
tempImageFileUri: Uri,
stationUuid: String,
imageManuallySet: Boolean = false
): Collection {
collection.stations.forEach { station ->
// find station by uuid
if (station.uuid == stationUuid) {
station.smallImage = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri,
Keys.SIZE_STATION_IMAGE_CARD,
Keys.STATION_IMAGE_FILE
).toString()
station.image = FileHelper.saveStationImage(
context,
station.uuid,
tempImageFileUri,
Keys.SIZE_STATION_IMAGE_MAXIMUM,
Keys.STATION_IMAGE_FILE
).toString()
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri)
station.imageManuallySet = imageManuallySet
}
}
// save and return collection
saveCollection(context, collection)
return collection
}
/* Clears an image folder for a given station */
fun clearImagesFolder(context: Context, station: Station) {
// clear image folder
val imagesFolder = File(
context.getExternalFilesDir(""),
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
)
FileHelper.clearFolder(imagesFolder, 0)
}
/* Deletes Images of a given station */
fun deleteStationImages(context: Context, station: Station) {
val imagesFolder = File(
context.getExternalFilesDir(""),
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
)
FileHelper.clearFolder(imagesFolder, 0, true)
}
/* Get station from collection for given UUID */
fun getStation(collection: Collection, stationUuid: String): Station {
collection.stations.forEach { station ->
if (station.uuid == stationUuid) {
return station
}
}
// fallback: return first station
return if (collection.stations.isNotEmpty()) {
collection.stations.first()
} else {
Station()
}
}
/* 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<MediaItem> {
val mediaItems: MutableList<MediaItem> = mutableListOf()
collection.stations.forEach { station ->
mediaItems.add(buildMediaItem(context, station))
}
return mediaItems
}
/* Returns media item for given station id */
fun getItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
return buildMediaItem(context, getStation(collection, stationUuid))
}
/* Returns media item for last played station */
fun getRecent(context: Context, collection: Collection): MediaItem {
return buildMediaItem(context, getStation(collection, PreferencesHelper.loadLastPlayedStationUuid()))
}
/* Returns the library root item */
fun getRootItem(): MediaItem {
val metadata: MediaMetadata = MediaMetadata.Builder()
.setTitle("Root Folder")
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
return MediaItem.Builder()
.setMediaId("[rootID]")
.setMediaMetadata(metadata)
.build()
}
/* Saves the playback state of a given station */
fun savePlaybackState(
context: Context,
collection: Collection,
stationUuid: String,
isPlaying: Boolean
): Collection {
collection.stations.forEach {
// reset playback state everywhere
it.isPlaying = false
// set given playback state at this station
if (it.uuid == stationUuid) {
it.isPlaying = isPlaying
}
}
// save collection and store modification date
collection.modificationDate = saveCollection(context, collection)
return collection
}
/* Saves collection of radio stations */
fun saveCollection(context: Context, collection: Collection, async: Boolean = true): Date {
Log.v(
TAG,
"Saving collection of radio stations to storage. Async = ${async}. Size = ${collection.stations.size}"
)
// get modification date
val date: Date = Calendar.getInstance().time
collection.modificationDate = date
// save collection to storage
when (async) {
true -> {
CoroutineScope(IO).launch {
// save collection on background thread
FileHelper.saveCollectionSuspended(context, collection, date)
// broadcast collection update
sendCollectionBroadcast(context, date)
}
}
false -> {
// save collection
FileHelper.saveCollection(context, collection, date)
// broadcast collection update
sendCollectionBroadcast(context, date)
}
}
// return modification date
return date
}
/* Creates station from playlist URLs and stream address URLs */
suspend fun createStationsFromUrl(query: String, lastCheckedAddress: String = String()): List<Station> {
val stationList: MutableList<Station> = mutableListOf()
val contentType: String = NetworkHelper.detectContentType(query).type.lowercase(Locale.getDefault())
val directInputCheck: DirectInputCheck? = null
// CASE: M3U playlist detected
if (Keys.MIME_TYPES_M3U.contains(contentType)) {
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
stationList.addAll(readM3uPlaylistContent(lines))
}
// CASE: PLS playlist detected
else if (Keys.MIME_TYPES_PLS.contains(contentType)) {
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
stationList.addAll(readPlsPlaylistContent(lines))
}
// CASE: stream address detected
else if (Keys.MIME_TYPES_MPEG.contains(contentType) or
Keys.MIME_TYPES_OGG.contains(contentType) or
Keys.MIME_TYPES_AAC.contains(contentType) or
Keys.MIME_TYPES_HLS.contains(contentType)) {
// process Icecast stream and extract metadata
directInputCheck?.processIcecastStream(query, stationList)
// create station and add to collection
val station = Station(name = query, streamUris = mutableListOf(query), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
if (lastCheckedAddress != query) {
stationList.add(station)
}
}
return stationList
}
/* Creates station from URI pointing to a local file */
fun createStationListFromContentUri(context: Context, contentUri: Uri): List<Station> {
val stationList: MutableList<Station> = mutableListOf()
val fileType: String = FileHelper.getContentType(context, contentUri)
// CASE: M3U playlist detected
if (Keys.MIME_TYPES_M3U.contains(fileType)) {
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
stationList.addAll(readM3uPlaylistContent(playlist))
}
// CASE: PLS playlist detected
else if (Keys.MIME_TYPES_PLS.contains(fileType)) {
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
stationList.addAll(readPlsPlaylistContent(playlist))
}
return stationList
}
/* Reads a m3u playlist and returns a list of stations */
private fun readM3uPlaylistContent(playlist: List<String>): List<Station> {
val stations: MutableList<Station> = mutableListOf()
var name = String()
var streamUri: String
var contentType: String
playlist.forEach { line ->
// get name of station
if (line.startsWith("#EXTINF:")) {
name = line.substringAfter(",").trim()
}
// get stream uri and check mime type
else if (line.isNotBlank() && !line.startsWith("#")) {
streamUri = line.trim()
// use the stream address as the name if no name is specified
if (name.isEmpty()) {
name = streamUri
}
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
// store station in list if mime type is supported
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
stations.add(station)
}
// reset name for the next station - useful if playlist does not provide name(s)
name = String()
}
}
return stations
}
/* Reads a pls playlist and returns a list of stations */
private fun readPlsPlaylistContent(playlist: List<String>): List<Station> {
val stations: MutableList<Station> = mutableListOf()
var name = String()
var streamUri: String
var contentType: String
playlist.forEachIndexed { index, line ->
// get stream uri and check mime type
if (line.startsWith("File")) {
streamUri = line.substringAfter("=").trim()
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
// look for the matching station name
val number: String = line.substring(4 /* File */, line.indexOf("="))
val lineBeforeIndex: Int = index - 1
val lineAfterIndex: Int = index + 1
// first: check the line before
if (lineBeforeIndex >= 0) {
val lineBefore: String = playlist[lineBeforeIndex]
if (lineBefore.startsWith("Title$number")) {
name = lineBefore.substringAfter("=").trim()
}
}
// then: check the line after
if (name.isEmpty() && lineAfterIndex < playlist.size) {
val lineAfter: String = playlist[lineAfterIndex]
if (lineAfter.startsWith("Title$number")) {
name = lineAfter.substringAfter("=").trim()
}
}
// fallback: use stream uri as name
if (name.isEmpty()) {
name = streamUri
}
// add station
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
stations.add(station)
}
}
}
return stations
}
/* Export collection of stations as M3U */
fun exportCollectionM3u(context: Context, collection: Collection) {
Log.v(TAG, "Exporting collection of stations as M3U")
// export collection as M3U - launch = fire & forget (no return value from save collection)
if (collection.stations.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<Station>
return collection
}
/* Get favicon address */
private fun getFaviconAddress(urlString: String): String {
var faviconAddress = String()
try {
var host: String = URL(urlString).host
if (!host.startsWith("www")) {
val index = host.indexOf(".")
host = "www" + host.substring(index)
}
faviconAddress = "http://$host/favicon.ico"
} catch (e: Exception) {
Log.e(TAG, "Unable to get base URL from $urlString.\n$e ")
}
return faviconAddress
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
}
}

View File

@@ -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<Long>
private lateinit var modificationDate: Date
/* Download station playlists */
fun downloadPlaylists(context: Context, playlistUrlStrings: Array<String>) {
// initialize main class variables, if necessary
initialize(context)
// convert array
val uris: Array<Uri> =
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<Uri> = 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<Uri> = 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<Uri>,
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<String> = 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<Long>,
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<NetworkHelper.ContentType> =
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<NetworkHelper.ContentType> =
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<Long>) {
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<Long> {
var inactiveDownloadsFound = false
val activeDownloadsList: ArrayList<Long> = 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
}
}

View File

@@ -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<String> {
val lines: MutableList<String> = 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")
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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<String> = 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<String> {
val lines = mutableListOf<String>()
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> =
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
}
}

View File

@@ -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<String>) {
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<String> {
var metadataHistory: MutableList<String> = 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
)
}
}

View File

@@ -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
)
)
}
}
}

View File

@@ -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
*/
}

View File

@@ -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<String> = mutableListOf()
/* Listener Interface */
interface UpdateHelperListener {
fun onStationUpdated(
collection: Collection,
positionPriorUpdate: Int,
positionAfterUpdate: Int
)
}
/* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
if (results.isNotEmpty()) {
CoroutineScope(IO).launch {
// get station from results
val station: Station = results[0].toStation()
// detect content type
val deferred: Deferred<NetworkHelper.ContentType> =
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ConstraintLayout> = BottomSheetBehavior.from(bottomSheet)
private var metadataHistory: MutableList<String>
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<String> = 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<String>?) {
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
*/
}

View File

@@ -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

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/decelerate_interpolator"
android:shareInterpolator="false">
<rotate
android:duration="500"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="360" />
</set>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/decelerate_interpolator"
android:shareInterpolator="false">
<rotate
android:duration="500"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="-360" />
</set>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="350"
android:propertyName="scaleY"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:valueFrom="0.2"
android:valueTo="1" />
</set>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="250"
android:propertyName="scaleY"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:valueFrom="0.2"
android:valueTo="1" />
</set>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="300"
android:propertyName="scaleY"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:valueFrom="0.2"
android:valueTo="1" />
</set>

View File

@@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="290.28dp"
android:height="400dp"
android:viewportWidth="290.29"
android:viewportHeight="400">
<path
android:pathData="m0.35,400l290.29,0l-0,-1.96l-290.64,0z"
android:fillColor="@color/player_button_buffering"/>
<path
android:pathData="M150.4,35.37m25.11,0a25.11,25.11 135,1 0,-50.23 0a25.11,25.11 45,1 0,50.23 0"
android:fillColor="#FFDAA8A8"/>
<path
android:pathData="m152.01,176.17a11.25,11.25 0,0 0,-8.51 -14.47,10.69 10.69,0 0,0 -1.49,-0.18l-26.46,-42.46 24.16,-22.98a9.64,9.64 45,1 0,-13.41 -13.86l-33.36,32.9 -0.07,0.08a8.74,8.74 0,0 0,0.61 10.41l37.07,43.83a10.55,10.55 0,0 0,-0.28 1.07,11.25 11.25,0 0,0 10.1,13.41q0.48,0.04 0.95,0.04a11.29,11.29 0,0 0,10.69 -7.81z"
android:fillColor="#FFDAA8A8"/>
<path
android:pathData="m191.73,384.91l12.54,0l5.96,-48.35l-18.5,0z"
android:fillColor="#FFDAA8A8"/>
<path
android:pathData="m188.04,397.57l41.44,0l-0,-1.03a16.24,16.24 0,0 0,-16.25 -16.24l-25.2,0z"
android:fillColor="@color/text_default"/>
<path
android:pathData="m67.12,367.57 l10.77,6.41 29.86,-38.5 -15.9,-9.47z"
android:fillColor="#FFDAA8A8"/>
<path
android:pathData="m93.09,397.76 l0.52,-0.88a16.25,16.25 0,0 0,-5.65 -22.27l-21.65,-12.89 -8.83,14.83z"
android:fillColor="@color/text_default"/>
<path
android:pathData="m211.83,363.61c8.4,-98.88 13.4,-190.75 -17.31,-228.22l-0.24,-0.29 -51.66,20.67 -0.09,0.18c-0.17,0.38 -17.36,38.17 -13.35,63.6l-12.74,58.62 -41.55,69.58a4.6,4.6 135,0 0,2.1 6.57l18.06,7.94a4.62,4.62 0,0 0,5.78 -1.81l45.2,-73.71 25.53,-56.53a1.53,1.53 0,0 1,2.92 0.47l14.15,133.03a4.59,4.59 45,0 0,4.58 4.11l14.04,0a4.63,4.63 0,0 0,4.59 -4.22z"
android:fillColor="@color/player_button_buffering"/>
<path
android:pathData="m194.34,136.17 l0.25,-0.12 0.04,-0.27c1.93,-13.51 -0.35,-28.52 -6.79,-44.61a35.42,35.42 0,0 0,-41.11 -21.21,35.37 35.37,45 0,0 -22.56,17.35 34.99,34.99 45,0 0,-2.43 28.04c8.11,23.72 18.64,45.92 18.74,46.14l0.22,0.46z"
android:fillColor="@color/text_default"/>
<path
android:pathData="m231.53,192.19a11.49,11.49 0,0 0,-8.23 -12.53l-48.31,-92.75a9.26,9.26 45,1 0,-15.75 9.72l49.45,92.1a11.16,11.16 0,0 0,-0.18 1.7,11.56 11.56,0 0,0 11.72,11.87 11.57,11.57 0,0 0,8.87 -4.35,11.43 11.43,135 0,0 2.44,-5.78z"
android:fillColor="#FFDAA8A8"/>
<path
android:pathData="m180.83,21.19l-39.88,0l-0,-17.38c8.75,-3.48 17.32,-6.44 22.5,0a17.38,17.38 135,0 1,17.38 17.38z"
android:fillColor="@color/player_button_buffering"/>
<path
android:pathData="m138.74,0.74c-23.84,0 -30.51,29.88 -30.51,46.74 -0,9.4 4.25,12.77 10.93,13.9l2.36,-12.59 5.53,13.13c1.88,0.01 3.85,-0.03 5.89,-0.06l1.87,-3.86 4.18,3.79c16.74,0.03 30.26,2.46 30.26,-14.31 -0,-16.86 -5.85,-46.74 -30.51,-46.74z"
android:fillColor="@color/player_button_buffering"/>
<path
android:pathData="m145.85,25.36l-0,-23.25a2.1,2.1 45,0 1,2.1 -2.1l4.19,0a2.1,2.1 135,0 1,2.1 2.1l-0,21.85a13.26,13.26 135,1 1,-8.39 1.4z"
android:fillColor="@color/text_default"/>
<path
android:pathData="M151.44,37.05m6.29,0a6.29,6.29 0,1 0,-12.58 0a6.29,6.29 0,1 0,12.58 0"
android:fillColor="@color/player_button_buffering"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M18,13h-5v5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1v-5H6c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5V6c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v5h5c0.55,0 1,0.45 1,1s-0.45,1 -1,1z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/app_onboarding_icons"
android:pathData="M13 19V7.83l4.88 4.88c0.39 0.39 1.03 0.39 1.42 0 0.39-0.39 0.39-1.02 0-1.41l-6.59-6.59c-0.39-0.39-1.02-0.39-1.41 0l-6.6 6.58c-0.39 0.39-0.39 1.02 0 1.41 0.39 0.39 1.02 0.39 1.41 0L11 7.83V19c0 0.55 0.45 1 1 1s1-0.45 1-1z" />
</vector>

View File

@@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="290.28dp"
android:height="400dp"
android:viewportWidth="290.29"
android:viewportHeight="400">
<path
android:pathData="m0.35,400l290.29,0l-0,-1.96l-290.64,0z"
android:fillColor="@color/icon_default"/>
<path
android:pathData="M150.4,35.37m25.11,0a25.11,25.11 135,1 0,-50.23 0a25.11,25.11 45,1 0,50.23 0"
android:fillColor="#FFD9BDBD"/>
<path
android:pathData="m152.01,176.17a11.25,11.25 0,0 0,-8.51 -14.47,10.69 10.69,0 0,0 -1.49,-0.18l-26.46,-42.46 24.16,-22.98a9.64,9.64 45,1 0,-13.41 -13.86l-33.36,32.9 -0.07,0.08a8.74,8.74 0,0 0,0.61 10.41l37.07,43.83a10.55,10.55 0,0 0,-0.28 1.07,11.25 11.25,0 0,0 10.1,13.41q0.48,0.04 0.95,0.04a11.29,11.29 0,0 0,10.69 -7.81z"
android:fillColor="#FFD9BDBD"/>
<path
android:pathData="m191.73,384.91l12.54,0l5.96,-48.35l-18.5,0z"
android:fillColor="#FFD9BDBD"/>
<path
android:pathData="m188.04,397.57l41.44,0l-0,-1.03a16.24,16.24 0,0 0,-16.25 -16.24l-25.2,0z"
android:fillColor="@color/player_sheet_background"/>
<path
android:pathData="m67.12,367.57 l10.77,6.41 29.86,-38.5 -15.9,-9.47z"
android:fillColor="#FFD9BDBD"/>
<path
android:pathData="m93.09,397.76 l0.52,-0.88a16.25,16.25 0,0 0,-5.65 -22.27l-21.65,-12.89 -8.83,14.83z"
android:fillColor="@color/player_sheet_background"/>
<path
android:pathData="m211.83,363.61c8.4,-98.88 13.4,-190.75 -17.31,-228.22l-0.24,-0.29 -51.66,20.67 -0.09,0.18c-0.17,0.38 -17.36,38.17 -13.35,63.6l-12.74,58.62 -41.55,69.58a4.6,4.6 135,0 0,2.1 6.57l18.06,7.94a4.62,4.62 0,0 0,5.78 -1.81l45.2,-73.71 25.53,-56.53a1.53,1.53 0,0 1,2.92 0.47l14.15,133.03a4.59,4.59 45,0 0,4.58 4.11l14.04,0a4.63,4.63 0,0 0,4.59 -4.22z"
android:fillColor="@color/icon_default"/>
<path
android:pathData="m194.34,136.17 l0.25,-0.12 0.04,-0.27c1.93,-13.51 -0.35,-28.52 -6.79,-44.61a35.42,35.42 0,0 0,-41.11 -21.21,35.37 35.37,45 0,0 -22.56,17.35 34.99,34.99 45,0 0,-2.43 28.04c8.11,23.72 18.64,45.92 18.74,46.14l0.22,0.46z"
android:fillColor="@color/player_sheet_background"/>
<path
android:pathData="m231.53,192.19a11.49,11.49 0,0 0,-8.23 -12.53l-48.31,-92.75a9.26,9.26 45,1 0,-15.75 9.72l49.45,92.1a11.16,11.16 0,0 0,-0.18 1.7,11.56 11.56,0 0,0 11.72,11.87 11.57,11.57 0,0 0,8.87 -4.35,11.43 11.43,135 0,0 2.44,-5.78z"
android:fillColor="#FFD9BDBD"/>
<path
android:pathData="m180.83,21.19l-39.88,0l-0,-17.38c8.75,-3.48 17.32,-6.44 22.5,0a17.38,17.38 135,0 1,17.38 17.38z"
android:fillColor="@color/icon_default"/>
<path
android:pathData="m138.74,0.74c-23.84,0 -30.51,29.88 -30.51,46.74 -0,9.4 4.25,12.77 10.93,13.9l2.36,-12.59 5.53,13.13c1.88,0.01 3.85,-0.03 5.89,-0.06l1.87,-3.86 4.18,3.79c16.74,0.03 30.26,2.46 30.26,-14.31 -0,-16.86 -5.85,-46.74 -30.51,-46.74z"
android:fillColor="@color/icon_default"/>
<path
android:pathData="m145.85,25.36l-0,-23.25a2.1,2.1 45,0 1,2.1 -2.1l4.19,0a2.1,2.1 135,0 1,2.1 2.1l-0,21.85a13.26,13.26 135,1 1,-8.39 1.4z"
android:fillColor="@color/player_sheet_background"/>
<path
android:pathData="M151.44,37.05m6.29,0a6.29,6.29 0,1 0,-12.58 0a6.29,6.29 0,1 0,12.58 0"
android:fillColor="@color/icon_default"/>
</vector>

View File

@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<group
android:name="leftGroup"
android:scaleY="0.5"
android:pivotY="20">
<path
android:pathData="m6,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
android:fillColor="@color/default_neutral_white" />
</group>
<group
android:name="centerGroup"
android:scaleY="0.5"
android:pivotY="20">
<path
android:pathData="m12,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
android:fillColor="@color/default_neutral_white" />
</group>
<group
android:name="rightGroup"
android:scaleY="0.5"
android:pivotY="20">
<path
android:pathData="m18,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
android:fillColor="@color/default_neutral_white"/>
</group>
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_audio_waves_36dp">
<target
android:name="leftGroup"
android:animation="@animator/scale_left_y" />
<target
android:name="centerGroup"
android:animation="@animator/scale_center_y" />
<target
android:name="rightGroup"
android:animation="@animator/scale_right_y" />
</animated-vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M9.27,4.49c-1.63,7.54,3.75,12.41,7.66,13.8C15.54,19.38,13.81,20,12,20c-4.41,0-8-3.59-8-8C4,8.55,6.2,5.6,9.27,4.49 M11.99,2.01C6.4,2.01,2,6.54,2,12c0,5.52,4.48,10,10,10c3.71,0,6.93-2.02,8.66-5.02c-7.51-0.25-12.09-8.43-8.32-14.97 C12.22,2.01,12.11,2.01,11.99,2.01L11.99,2.01z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M9,16.17L5.53,12.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41l4.18,4.18c0.39,0.39 1.02,0.39 1.41,0L20.29,7.71c0.39,-0.39 0.39,-1.02 0,-1.41 -0.39,-0.39 -1.02,-0.39 -1.41,0L9,16.17z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M14.71 6.71c-0.39-0.39-1.02-0.39-1.41 0L8.71 11.3c-0.39 0.39-0.39 1.02 0 1.41l4.59 4.59c0.39 0.39 1.02 0.39 1.41 0 0.39-0.39 0.39-1.02 0-1.41L10.83 12l3.88-3.88c0.39-0.39 0.38-1.03 0-1.41z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M9.29 6.71c-0.39 0.39-0.39 1.02 0 1.41L13.17 12l-3.88 3.88c-0.39 0.39-0.39 1.02 0 1.41 0.39 0.39 1.02 0.39 1.41 0l4.59-4.59c0.39-0.39 0.39-1.02 0-1.41L10.7 6.7c-0.38-0.38-1.02-0.38-1.41 0.01z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="m11.999,2.623c-5.54,0 -9.999,4.462 -9.999,10.001 -0,1.878 0.529,3.721 1.528,5.313l8.336,-10.778c0.06,-0.077 0.209,-0.077 0.27,0l3.482,4.502h-2.494l0.054,0.199h2.594l0.736,0.949h-3.062l0.086,0.311h3.217l0.647,0.836h-3.631l0.113,0.402h3.827l0.579,0.745h-4.198l0.137,0.49h4.438l0.508,0.655h-4.764l0.152,0.542h5.031l0.468,0.606h-5.33l0.152,0.542h5.598c0.998,-1.592 1.528,-3.435 1.528,-5.313 0,-5.54 -4.462,-10.001 -10.001,-10.001zM15.044,18.543 L15.196,19.085h4.438c0.145,-0.171 0.294,-0.361 0.428,-0.542zM15.365,19.69 L15.515,20.232h2.969c0.194,-0.166 0.405,-0.357 0.594,-0.542zM15.684,20.838 L15.836,21.377h1.003c0.296,-0.17 0.571,-0.343 0.868,-0.539z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M15,20H5V7c0,-0.55 -0.45,-1 -1,-1h0C3.45,6 3,6.45 3,7v13c0,1.1 0.9,2 2,2h10c0.55,0 1,-0.45 1,-1v0C16,20.45 15.55,20 15,20zM20,16V4c0,-1.1 -0.9,-2 -2,-2H9C7.9,2 7,2.9 7,4v12c0,1.1 0.9,2 2,2h9C19.1,18 20,17.1 20,16zM18,16H9V4h9V16z" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_lightweight_background"
android:pathData="M0,0h24v24h-24z" />
<path
android:fillColor="@color/icon_lightweight"
android:pathData="m22.5,13.5c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75c0,-4.969 -4.031,-9 -9,-9s-9,4.031 -9,9c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75c0,-5.801 4.699,-10.5 10.5,-10.5s10.5,4.699 10.5,10.5zM12,6.75c-3.727,0 -6.75,3.023 -6.75,6.75 0,0.414 0.336,0.75 0.75,0.75s0.75,-0.336 0.75,-0.75c0,-2.898 2.352,-5.25 5.25,-5.25s5.25,2.352 5.25,5.25c0,0.414 0.336,0.75 0.75,0.75s0.75,-0.336 0.75,-0.75c0,-3.727 -3.023,-6.75 -6.75,-6.75zM15,13.5c-0.004,1.363 -0.93,2.555 -2.25,2.895v3.855c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75v-3.855c-1.512,-0.391 -2.469,-1.872 -2.207,-3.411s1.66,-2.617 3.215,-2.484c1.555,0.137 2.746,1.441 2.742,3zM13.5,13.5c0,-0.828 -0.672,-1.5 -1.5,-1.5s-1.5,0.672 -1.5,1.5 0.672,1.5 1.5,1.5 1.5,-0.672 1.5,-1.5z"
android:strokeAlpha="0" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M16.59,9H15V4c0,-0.55 -0.45,-1 -1,-1h-4c-0.55,0 -1,0.45 -1,1v5H7.41c-0.89,0 -1.34,1.08 -0.71,1.71l4.59,4.59c0.39,0.39 1.02,0.39 1.41,0l4.59,-4.59c0.63,-0.63 0.19,-1.71 -0.7,-1.71zM5,19c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H6c-0.55,0 -1,0.45 -1,1z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M3,17.46v3.04c0,0.28 0.22,0.5 0.5,0.5h3.04c0.13,0 0.26,-0.05 0.35,-0.15L17.81,9.94l-3.75,-3.75L3.15,17.1c-0.1,0.1 -0.15,0.22 -0.15,0.36zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/list_card_mark_starred_star"
android:pathData="M13.35 20.13c-0.76 0.69-1.93 0.69-2.69-0.01l-0.11-0.1C5.3 15.27 1.87 12.16 2 8.28c0.06-1.7 0.93-3.33 2.34-4.29 2.64-1.8 5.9-0.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41 0.96 2.28 2.59 2.34 4.29 0.14 3.88-3.3 6.99-8.55 11.76l-0.1 0.09z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M13.35 20.13c-0.76 0.69-1.93 0.69-2.69-0.01l-0.11-0.1C5.3 15.27 1.87 12.16 2 8.28c0.06-1.7 0.93-3.33 2.34-4.29 2.64-1.8 5.9-0.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41 0.96 2.28 2.59 2.34 4.29 0.14 3.88-3.3 6.99-8.55 11.76l-0.1 0.09z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/icon_default"
android:pathData="M220,780h150L370,530h220v250h150L740,390L480,195 220,390v390ZM220,840q-24.75,0 -42.38,-17.63T160,780L160,390q0,-14.25 6.38,-27T184,342l260,-195q8.3,-6 17.34,-9 9.05,-3 18.85,-3 9.8,0 18.72,3 8.91,3 17.09,9l260,195q11.25,8.25 17.63,21T800,390v390q0,24.75 -17.63,42.38T740,840L530,840L530,590L430,590v250L220,840ZM480,487Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM18,19L6,19c-0.55,0 -1,-0.45 -1,-1L5,6c0,-0.55 0.45,-1 1,-1h12c0.55,0 1,0.45 1,1v12c0,0.55 -0.45,1 -1,1zM13.56,12.81l-2.35,3.02 -1.56,-1.88c-0.2,-0.25 -0.58,-0.24 -0.78,0.01l-1.74,2.23c-0.26,0.33 -0.02,0.81 0.39,0.81h8.98c0.41,0 0.65,-0.47 0.4,-0.8l-2.55,-3.39c-0.19,-0.26 -0.59,-0.26 -0.79,0z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM18,19L6,19c-0.55,0 -1,-0.45 -1,-1L5,6c0,-0.55 0.45,-1 1,-1h12c0.55,0 1,0.45 1,1v12c0,0.55 -0.45,1 -1,1zM13.56,12.81l-2.35,3.02 -1.56,-1.88c-0.2,-0.25 -0.58,-0.24 -0.78,0.01l-1.74,2.23c-0.26,0.33 -0.02,0.81 0.39,0.81h8.98c0.41,0 0.65,-0.47 0.4,-0.8l-2.55,-3.39c-0.19,-0.26 -0.59,-0.26 -0.79,0z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="40"
android:viewportHeight="24">
<group android:scaleX="0.42"
android:scaleY="0.252"
android:translateX="11.6"
android:translateY="8.976">
<path
android:pathData="m2,0c-1.1,0 -2,0.9 -2,2v20c0,1.1 0.9,2 2,2h36c1.1,0 2,-0.9 2,-2v-20c0,-1.1 -0.9,-2 -2,-2h-36zM12,4a8,8 0,0 1,8 8,8 8,0 0,1 -8,8 8,8 0,0 1,-8 -8,8 8,0 0,1 8,-8zM25,5h9c1.1,0 2,0.9 2,2v5c0,1.1 -0.9,2 -2,2h-9c-1.1,0 -2,-0.9 -2,-2v-5c0,-1.1 0.9,-2 2,-2zM24.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5zM29.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5zM34.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5z"
android:fillColor="#f5cf87"/>
<path
android:pathData="m24.292,7.558h0.771v2.068q0,0.492 0.029,0.638 0.049,0.234 0.234,0.378 0.188,0.141 0.51,0.141 0.328,0 0.495,-0.133 0.167,-0.135 0.201,-0.331 0.034,-0.195 0.034,-0.648v-2.112h0.771v2.005q0,0.688 -0.063,0.971t-0.232,0.479q-0.167,0.195 -0.448,0.313 -0.281,0.115 -0.734,0.115 -0.547,0 -0.831,-0.125 -0.281,-0.128 -0.445,-0.328 -0.164,-0.203 -0.216,-0.424 -0.076,-0.328 -0.076,-0.969z"
android:fillColor="#f5cf87"/>
<path
android:pathData="m28.151,11.376v-3.818h1.622q0.612,0 0.888,0.104 0.279,0.102 0.445,0.365 0.167,0.263 0.167,0.602 0,0.43 -0.253,0.711 -0.253,0.279 -0.755,0.352 0.25,0.146 0.411,0.32 0.164,0.174 0.44,0.62l0.466,0.745h-0.922l-0.557,-0.831q-0.297,-0.445 -0.406,-0.56 -0.109,-0.117 -0.232,-0.159 -0.122,-0.044 -0.388,-0.044h-0.156v1.594zM28.922,9.173h0.57q0.555,0 0.693,-0.047t0.216,-0.161q0.078,-0.115 0.078,-0.286 0,-0.193 -0.104,-0.31 -0.102,-0.12 -0.289,-0.151 -0.094,-0.013 -0.563,-0.013h-0.602z"
android:fillColor="#f5cf87"/>
<path
android:pathData="m32.021,11.376v-3.786h0.771v3.143h1.917v0.643z"
android:fillColor="#f5cf87"/>
</group>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M12 9c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0-6c1.1 0 2 .9 2 2s-0.9 2-2 2-2-0.9-2-2 .9-2 2-2zm0 8.55C9.64 9.35 6.48 8 3 8v11c3.48 0 6.64 1.35 9 3.55 2.36-2.19 5.52-3.55 9-3.55V8c-3.48 0-6.64 1.35-9 3.55zm7 5.58c-2.53.34-4.93 1.3-7 2.82-2.06-1.52-4.47-2.49-7-2.83v-6.95c2.1.38 4.05 1.35 5.64 2.83L12 14.28l1.36-1.27c1.59-1.48 3.54-2.45 5.64-2.83v6.95z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/icon_default"
android:pathData="M12,5v8.55c-0.94,-0.54 -2.1,-0.75 -3.33,-0.32 -1.34,0.48 -2.37,1.67 -2.61,3.07 -0.46,2.74 1.86,5.08 4.59,4.65 1.96,-0.31 3.35,-2.11 3.35,-4.1V7h2c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2h-2c-1.1,0 -2,0.9 -2,2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M15.9,5c-0.17,0 -0.32,0.09 -0.41,0.23l-0.07,0.15 -5.18,11.65c-0.16,0.29 -0.26,0.61 -0.26,0.96 0,1.11 0.9,2.01 2.01,2.01 0.96,0 1.77,-0.68 1.96,-1.59l0.01,-0.03L16.4,5.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM2.06,10.06c0.51,0.51 1.33,0.55 1.89,0.09 2.76,-2.26 6.24,-3.18 9.58,-2.76l1.19,-2.68c-4.35,-0.78 -8.96,0.3 -12.57,3.25 -0.64,0.53 -0.68,1.51 -0.09,2.1zM21.94,10.06c0.59,-0.59 0.55,-1.57 -0.1,-2.1 -1.36,-1.11 -2.86,-1.95 -4.44,-2.53l-0.53,2.82c1.13,0.47 2.19,1.09 3.17,1.89 0.58,0.46 1.39,0.43 1.9,-0.08zM17.91,14.09c0.6,-0.6 0.56,-1.63 -0.14,-2.12 -0.46,-0.33 -0.94,-0.61 -1.44,-0.86l-0.55,2.92c0.11,0.07 0.22,0.14 0.32,0.22 0.57,0.4 1.33,0.32 1.81,-0.16zM6.08,14.08c0.5,0.5 1.27,0.54 1.85,0.13 0.94,-0.66 2.01,-1.06 3.1,-1.22l1.28,-2.88c-2.13,-0.06 -4.28,0.54 -6.09,1.84 -0.69,0.51 -0.74,1.53 -0.14,2.13z" />
</vector>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M6,6h12v12H6z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42dp"
android:height="42dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M8 6.82v10.36c0 0.79 0.87 1.27 1.54 0.84l8.14-5.18c 0.62-0.39 0.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M12,5v8.55c-0.94,-0.54 -2.1,-0.75 -3.33,-0.32 -1.34,0.48 -2.37,1.67 -2.61,3.07 -0.46,2.74 1.86,5.08 4.59,4.65 1.96,-0.31 3.35,-2.11 3.35,-4.1V7h2c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2h-2c-1.1,0 -2,0.9 -2,2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/default_neutral_white"
android:pathData="M8,6h8c1.1,0 2,0.9 2,2v8c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2V8c0,-1.1 0.9,-2 2,-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/list_card_delete_icon"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM16,13L8,13c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h8c0.55,0 1,0.45 1,1s-0.45,1 -1,1z" />
</vector>

View File

@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="m5.5,22q-0.6,0 -1.05,-0.45t-0.45,-1.05v-17q0,-0.6 0.45,-1.05t1.05,-0.45h8.4q0.311,0 0.593,0.125t0.482,0.325l4.575,4.575q0.2,0.2 0.325,0.482t0.125,0.593v12.4q0,0.6 -0.45,1.05t-1.05,0.45zM13.775,7.4v-3.9h-8.275v17h13v-12.35h-3.975q-0.319,0 -0.534,-0.216t-0.216,-0.534zM5.5,3.5v4.65,-4.65 17z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m7.713,14.054 l-0.988,-3.22l-0.025,0q0.006,0.115 0.017,0.348 0.014,0.23 0.025,0.491 0.011,0.261 0.011,0.472l0,1.909l-0.778,0l0,-4.105l1.185,0l0.971,3.139l0.017,0l1.03,-3.139l1.185,0l0,4.105l-0.811,0l0,-1.943q0,-0.194 0.006,-0.446 0.008,-0.253 0.02,-0.48 0.011,-0.23 0.017,-0.345l-0.025,0l-1.058,3.215z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m13.819,10.867q0,0.284 -0.118,0.491t-0.32,0.34q-0.199,0.132 -0.449,0.194l0,0.017q0.494,0.062 0.75,0.303 0.258,0.241 0.258,0.646 0,0.359 -0.177,0.643 -0.174,0.284 -0.539,0.446t-0.941,0.163q-0.34,0 -0.635,-0.056 -0.292,-0.053 -0.55,-0.166l0,-0.738q0.264,0.135 0.553,0.205 0.289,0.067 0.539,0.067 0.466,0 0.651,-0.16 0.188,-0.163 0.188,-0.455 0,-0.171 -0.087,-0.289 -0.087,-0.118 -0.303,-0.18 -0.213,-0.062 -0.598,-0.062l-0.312,0l0,-0.665l0.317,0q0.379,0 0.576,-0.07 0.199,-0.073 0.27,-0.197 0.073,-0.126 0.073,-0.286 0,-0.219 -0.135,-0.343 -0.135,-0.124 -0.449,-0.124 -0.197,0 -0.359,0.051 -0.16,0.048 -0.289,0.118 -0.129,0.067 -0.227,0.132l-0.401,-0.598q0.241,-0.174 0.564,-0.289 0.326,-0.115 0.775,-0.115 0.635,0 1.005,0.256t0.371,0.722z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m18.025,9.949l0,2.656q0,0.424 -0.188,0.766 -0.185,0.34 -0.564,0.539 -0.376,0.199 -0.949,0.199 -0.814,0 -1.241,-0.416 -0.427,-0.416 -0.427,-1.101l0,-2.645l0.868,0l0,2.513q0,0.508 0.208,0.713t0.615,0.205q0.286,0 0.463,-0.098 0.18,-0.098 0.264,-0.303 0.084,-0.205 0.084,-0.522l0,-2.507z" />
</vector>

View File

@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="m5.5,22q-0.6,0 -1.05,-0.45t-0.45,-1.05v-17q0,-0.6 0.45,-1.05t1.05,-0.45h8.4q0.311,0 0.593,0.125t0.482,0.325l4.575,4.575q0.2,0.2 0.325,0.482t0.125,0.593v12.4q0,0.6 -0.45,1.05t-1.05,0.45zM13.775,7.4v-3.9h-8.275v17h13v-12.35h-3.975q-0.319,0 -0.534,-0.216t-0.216,-0.534zM5.5,3.5v4.65,-4.65 17z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m8.525,9.86q0.829,0 1.21,0.357 0.381,0.354 0.381,0.979 0,0.281 -0.085,0.539 -0.085,0.255 -0.278,0.454 -0.19,0.199 -0.51,0.316 -0.319,0.114 -0.788,0.114l-0.39,0l0,1.523l-0.908,0l0,-4.283zM8.479,10.604l-0.413,0l0,1.271l0.299,0q0.255,0 0.442,-0.067t0.29,-0.211 0.103,-0.369q0,-0.316 -0.176,-0.469 -0.176,-0.155 -0.545,-0.155z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m10.925,14.143l0,-4.283l0.908,0l0,3.533l1.737,0l0,0.75z" />
<path
android:fillColor="@color/icon_default"
android:pathData="m16.843,12.953q0,0.381 -0.185,0.662 -0.185,0.281 -0.539,0.434 -0.352,0.152 -0.855,0.152 -0.223,0 -0.437,-0.029 -0.211,-0.029 -0.407,-0.085 -0.193,-0.059 -0.369,-0.144l0,-0.844q0.305,0.135 0.633,0.243 0.328,0.108 0.65,0.108 0.223,0 0.357,-0.059 0.138,-0.059 0.199,-0.161t0.062,-0.234q0,-0.161 -0.108,-0.275t-0.299,-0.214q-0.188,-0.1 -0.425,-0.214 -0.149,-0.07 -0.325,-0.17 -0.176,-0.103 -0.334,-0.249t-0.261,-0.354q-0.1,-0.211 -0.1,-0.504 0,-0.384 0.176,-0.656t0.501,-0.416q0.328,-0.146 0.773,-0.146 0.334,0 0.636,0.079 0.305,0.076 0.636,0.223l-0.293,0.706q-0.296,-0.12 -0.53,-0.185 -0.234,-0.067 -0.478,-0.067 -0.17,0 -0.29,0.056 -0.12,0.053 -0.182,0.152 -0.062,0.097 -0.062,0.226 0,0.152 0.088,0.258 0.091,0.103 0.27,0.199 0.182,0.097 0.451,0.226 0.328,0.155 0.56,0.325 0.234,0.167 0.36,0.396 0.126,0.226 0.126,0.563z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:pathData="M0 0h24v24H0V0z" />
<path
android:fillColor="@color/icon_default"
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/player_button_background"
android:pathData="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 13.5v-7c0-0.41 0.47-0.65 0.8-0.4l4.67 3.5c 0.27 0.2 0.27 0.6 0 0.8l-4.67 3.5c-0.33 0.25-0.8.01-0.8-0.4z" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/player_sheet_icon"
android:pathData="M14 1h-4c-0.55 0-1 0.45-1 1s0.45 1 1 1h4c0.55 0 1-0.45 1-1s-0.45-1-1-1zm-2 13c0.55 0 1-0.45 1-1V9c0-0.55-0.45-1-1-1s-1 0.45-1 1v4c0 0.55 0.45 1 1 1zm7.03-6.61l0.75-0.75c0.38-0.38 0.39 -1.01 0-1.4l-0.01-0.01c-0.39-0.39-1.01-0.38-1.4 0l-0.75 0.75 C16.07 4.74 14.12 4 12 4c-4.8 0-8.88 3.96-9 8.76C2.87 17.84 6.94 22 12 22c4.98 0 9-4.03 9-9 0-2.12-0.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"
android:strokeColor="@color/player_sheet_icon" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/icon_default"
android:pathData="M7.4,10h1.59v5c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-5h1.59c0.89,0 1.34,-1.08 0.71,-1.71L12.7,3.7c-0.39,-0.39 -1.02,-0.39 -1.41,0L6.7,8.29C6.07,8.92 6.51,10 7.4,10zM5,19c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H6C5.45,18 5,18.45 5,19z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- View is "selected" -->
<!-- <item android:state_selected="true"-->
<!-- android:drawable="@drawable/ic_circular_button_playback_selected_56dp" />-->
<item android:drawable="@drawable/shape_player_button_small_selected" android:state_focused="true" />
<!-- Default state. -->
<item android:drawable="@drawable/shape_player_button_small" />
</selector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- View is "selected" -->
<item android:drawable="@drawable/shape_search_result_item_selected" android:state_selected="true" />
<!-- Default state. -->
<item android:drawable="@drawable/shape_search_result_item" />
</selector>

View File

@@ -0,0 +1,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/list_card_cover_background" />
<corners android:radius="4dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="56dp"
android:height="56dp" />
<solid android:color="@color/player_button_background" />
</shape>

View File

@@ -0,0 +1,10 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:width="2dp"
android:color="@color/default_neutral_lighter" />
<size
android:width="56dp"
android:height="56dp" />
<solid android:color="@color/player_button_background" />
</shape>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="-4dp"
android:left="-4dp"
android:right="-4dp">
<shape android:shape="rectangle">
<solid android:color="@color/player_sheet_background" />
<corners
android:topLeftRadius="28dp"
android:topRightRadius="28dp" />
<stroke
android:width="3dp"
android:color="@color/list_card_stroke_background" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/search_result_background" />
<corners android:radius="16dp" />
<stroke android:width="3dp" android:color="@color/list_card_stroke_background" />
</shape>

Some files were not shown because too many files have changed in this diff Show More