1.9.9.8
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<!-- Google Services разрешения (опциональные) -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="replace"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<application
|
||||
android:name=".URLRadio"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="tiramisu">
|
||||
|
||||
<!-- Google Services meta-data (опциональные) -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"
|
||||
tools:node="replace" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher"
|
||||
tools:node="replace" />
|
||||
|
||||
<!-- Yandex Ads -->
|
||||
<meta-data
|
||||
android:name="com.yandex.mobile.ads.AUTOMATIC_SDK_INITIALIZATION"
|
||||
android:value="false" />
|
||||
|
||||
<!-- Firebase meta-data (опциональные) -->
|
||||
<meta-data
|
||||
android:name="firebase_performance_logcat_enabled"
|
||||
android:value="true"
|
||||
tools:node="replace" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="OPEN_MAIN_ACTIVITY" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- <activity-->
|
||||
<!-- android:name="com.yaros.RadioUrl.ui.SplashScreen"-->
|
||||
<!-- android:configChanges="orientation|keyboardHidden|screenSize"-->
|
||||
<!-- android:exported="true">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.intent.action.MAIN" />-->
|
||||
<!-- <category android:name="android.intent.category.LAUNCHER" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </activity>-->
|
||||
|
||||
<service
|
||||
android:name=".PlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
<action android:name="com.yaros.RadioUrl.action.START_PLAYER_SERVICE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Firebase Messaging Service (опциональный - работает только если Firebase доступен) -->
|
||||
<service
|
||||
android:name=".helpers.FMessagingService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
tools:node="replace">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Firebase Messaging meta-data (опциональные) -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_launcher"
|
||||
tools:node="replace" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/list_card_background"
|
||||
tools:node="replace" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
tools:replace="android:authorities">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"
|
||||
tools:replace="android:resource"/>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,195 @@
|
||||
package com.yaros.RadioUrl
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import java.util.*
|
||||
|
||||
|
||||
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
|
||||
|
||||
// intent actions
|
||||
const val ACTION_SHOW_PLAYER: String = "com.yaros.RadioUrl.action.SHOW_PLAYER"
|
||||
const val ACTION_COLLECTION_CHANGED: String = "com.yaros.RadioUrl.action.COLLECTION_CHANGED"
|
||||
const val ACTION_START: String = "com.yaros.RadioUrl.action.START"
|
||||
const val ACTION_PLAY_STREAM: String = "com.yaros.RadioUrl.action.PLAY_STREAM"
|
||||
const val IMMEDIATE_APP_UPDATE_REQ_CODE: Int = 124
|
||||
private const val serialVersionUID = 1L
|
||||
const val PERMISSIONS_REQUEST: Int = 102
|
||||
const val SWH_ADDRESS: String = "https://soundwaveradio.ru/admin_panel/api/"
|
||||
const val SERVER_API_KEY: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"
|
||||
const val DELAY_SPLASH: Int = 500
|
||||
const val DELAY_PROGRESS: Int = 100
|
||||
const val DELAY_CLICK: Int = 150
|
||||
const val DELAY_ACTION_CLICK: Int = 250
|
||||
const val WAITING_TIME_NEXT_ITEM_CLICK: Int = 1000
|
||||
|
||||
var metadata: String? = null
|
||||
var albumArt: String? = null
|
||||
var exoPlayer: ExoPlayer? = null
|
||||
var is_playing: Boolean = false
|
||||
var radio_type: Boolean = true
|
||||
var is_app_open: Boolean = false
|
||||
var position: Int = 0
|
||||
var CURRENT_ID: String = ""
|
||||
var colorBitmap: Bitmap? = null
|
||||
var isPlayerExpanded: Boolean = false
|
||||
|
||||
var progressVisibility: Boolean = false
|
||||
|
||||
const val MAX_NUMBER_OF_NATIVE_AD_DISPLAYED: Int = 50
|
||||
|
||||
const val THEME_LIGHT: Int = 0
|
||||
const val THEME_DARK: Int = 1
|
||||
const val THEME_PRIMARY: Int = 2
|
||||
var isAppOpen: Boolean = false
|
||||
var isForeground: Boolean = false
|
||||
var isPausedFromClick: Boolean = false
|
||||
var isRadioPlaying: Boolean = false
|
||||
|
||||
// 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_STATION_NAME: String = "STATION_NAME"
|
||||
const val EXTRA_STATION_IMAGE: String = "STATION_IMAGE"
|
||||
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"
|
||||
const val PREF_CACHE_ENABLED: String = "CACHE_ENABLED"
|
||||
const val PREF_CACHE_MAX_SIZE: String = "CACHE_MAX_SIZE"
|
||||
|
||||
// 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
|
||||
const val DIALOG_CLEAR_CACHE: Int = 6
|
||||
|
||||
// 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.yaros.RadioUrl/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"
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.yaros.RadioUrl
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.navigateUp
|
||||
import com.yaros.RadioUrl.helpers.AdManager
|
||||
import com.yaros.RadioUrl.helpers.GoogleServicesHelper
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper
|
||||
import com.yaros.RadioUrl.helpers.FileHelper
|
||||
import com.yaros.RadioUrl.helpers.ImportHelper
|
||||
import com.yaros.RadioUrl.helpers.PermissionHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.ui.ReviewManager
|
||||
import timber.log.Timber
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
private var analytics: Any? = null // Nullable для опциональной поддержки Firebase
|
||||
private lateinit var adManager: AdManager
|
||||
private lateinit var reviewManager: ReviewManager
|
||||
|
||||
@UnstableApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
if (PreferencesHelper.isHouseKeepingNecessary()) {
|
||||
ImportHelper.removeDefaultStationImageUris(this)
|
||||
if (PreferencesHelper.loadCollectionSize() != -1) {
|
||||
PreferencesHelper.saveEditStationsEnabled(true)
|
||||
}
|
||||
PreferencesHelper.saveHouseKeepingNecessaryState()
|
||||
}
|
||||
|
||||
// Инициализация Firebase Analytics (если доступен)
|
||||
initializeFirebaseAnalytics()
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
reviewManager = ReviewManager(this)
|
||||
reviewManager.initialize()
|
||||
|
||||
PermissionHelper.checkEssentialPermissions(this)
|
||||
|
||||
FileHelper.createNomediaFile(getExternalFilesDir(null))
|
||||
setSupportActionBar(findViewById(R.id.main_toolbar))
|
||||
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
val navView: BottomNavigationView = findViewById(R.id.bottomNavigationView)
|
||||
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(R.id.navigation_home, R.id.navigation_categories, R.id.navigation_menu)
|
||||
)
|
||||
|
||||
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||
navView.setupWithNavController(navController)
|
||||
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
startPlayerService()
|
||||
|
||||
// Инициализация Firebase Messaging (если доступен)
|
||||
initializeFirebaseMessaging()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
"pscid",
|
||||
"Новости",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
adManager = (application as URLRadio).adManager
|
||||
adManager.setActivity(this)
|
||||
adManager.initBannerAd(this)
|
||||
adManager.loadBannerAd()
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасная инициализация Firebase Analytics
|
||||
*/
|
||||
private fun initializeFirebaseAnalytics() {
|
||||
if (GoogleServicesHelper.isFirebaseAvailable()) {
|
||||
analytics = GoogleServicesHelper.getFirebaseAnalytics(this)
|
||||
if (analytics != null) {
|
||||
Timber.tag("MainActivity").d("Firebase Analytics initialized")
|
||||
} else {
|
||||
Timber.tag("MainActivity").w("Firebase Analytics initialization failed")
|
||||
}
|
||||
} else {
|
||||
Timber.tag("MainActivity").i("Firebase Analytics not available - running without analytics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасная инициализация Firebase Messaging
|
||||
*/
|
||||
private fun initializeFirebaseMessaging() {
|
||||
if (!GoogleServicesHelper.isFirebaseAvailable()) {
|
||||
Timber.tag("MainActivity").i("Firebase Messaging not available - push notifications disabled")
|
||||
return
|
||||
}
|
||||
|
||||
GoogleServicesHelper.getFirebaseMessagingToken(
|
||||
onSuccess = { token ->
|
||||
Timber.tag("MainActivity").d("FCM Token: $token")
|
||||
// Здесь можно отправить токен на сервер
|
||||
},
|
||||
onFailure = { exception ->
|
||||
Timber.tag("MainActivity").w(exception, "Failed to get FCM token")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private fun startPlayerService() {
|
||||
if (!isServiceRunning(PlayerService::class.java)) {
|
||||
val serviceIntent = Intent(this, PlayerService::class.java)
|
||||
ContextCompat.startForegroundService(this, serviceIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServiceRunning(serviceClass: Class<*>): Boolean {
|
||||
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return manager.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
val navController = findNavController(R.id.nav_graph)
|
||||
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.tag("ONRESUME").d("OnResume")
|
||||
adManager.loadBannerAd()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
adManager.destroyBannerAd()
|
||||
}
|
||||
|
||||
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_THEME_SELECTION -> {
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
package com.yaros.RadioUrl
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.BatteryManager
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.*
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.SeekParameters
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.ui.PlayerNotificationManager
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.yaros.RadioUrl.helpers.AudioHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkMonitor
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@UnstableApi
|
||||
class PlayerService : MediaSessionService(), Player.Listener {
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var networkMonitor: NetworkMonitor
|
||||
private var isForegroundService = false
|
||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||
|
||||
private var hasAudioFocus = false
|
||||
private var playbackDelayed = false
|
||||
private var resumeOnFocusGain = false
|
||||
|
||||
private val audioFocusRequest = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
Timber.tag("PlayerService").i("Audio focus changed: $focusChange")
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
Timber.tag("PlayerService").i("Audio focus gained")
|
||||
hasAudioFocus = true
|
||||
|
||||
exoPlayer?.let { player ->
|
||||
// Восстанавливаем громкость если была уменьшена (ducking)
|
||||
if (player.volume < 1.0f) {
|
||||
player.volume = 1.0f
|
||||
Timber.tag("PlayerService").i("Volume restored to 100%")
|
||||
}
|
||||
|
||||
// Если воспроизведение было отложено - запускаем
|
||||
if (playbackDelayed) {
|
||||
Timber.tag("PlayerService").i("Starting delayed playback")
|
||||
player.play()
|
||||
playbackDelayed = false
|
||||
}
|
||||
// Если нужно возобновить после временной паузы
|
||||
else if (resumeOnFocusGain) {
|
||||
Timber.tag("PlayerService").i("Resuming playback after transient loss")
|
||||
player.play()
|
||||
resumeOnFocusGain = false
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Timber.tag("PlayerService").i("Audio focus lost permanently")
|
||||
hasAudioFocus = false
|
||||
resumeOnFocusGain = false
|
||||
playbackDelayed = false
|
||||
exoPlayer?.pause()
|
||||
// Останавливаем сервис при постоянной потере фокуса
|
||||
stopServiceGracefully()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
Timber.tag("PlayerService").i("Audio focus lost temporarily")
|
||||
hasAudioFocus = false
|
||||
resumeOnFocusGain = exoPlayer?.isPlaying == true
|
||||
exoPlayer?.pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
Timber.tag("PlayerService").i("Audio focus lost, can duck")
|
||||
// Уменьшаем громкость вместо паузы
|
||||
exoPlayer?.volume = 0.2f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: PlayerService
|
||||
get() = this@PlayerService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initComponents()
|
||||
|
||||
// Немедленно запускаем foreground service с временным уведомлением
|
||||
// чтобы избежать RemoteServiceException
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification())
|
||||
isForegroundService = true
|
||||
|
||||
setupPlayer() // Синхронная инициализация
|
||||
setupMediaSession() // Должна быть после setupPlayer()
|
||||
setupNotificationManager() // Должна быть после setupMediaSession()
|
||||
setupNetworkMonitor()
|
||||
}
|
||||
|
||||
private fun initComponents() {
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
networkMonitor = NetworkMonitor(this)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createDataSourceFactory(): DataSource.Factory {
|
||||
return try {
|
||||
OkHttpDataSource.Factory(NetworkHelper.client)
|
||||
.setUserAgent("SoundWaveRadio/1.0")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error creating data source factory: ${e.message}")
|
||||
// Fallback to default
|
||||
OkHttpDataSource.Factory(NetworkHelper.client)
|
||||
.setUserAgent("SoundWaveRadio/1.0")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPlayer() {
|
||||
try {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(
|
||||
this,
|
||||
createDataSourceFactory()
|
||||
)
|
||||
exoPlayer = ExoPlayer.Builder(this)
|
||||
.setHandleAudioBecomingNoisy(true) // Автоматически останавливает при отключении наушников
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
MIN_BUFFER_MS,
|
||||
MAX_BUFFER_MS,
|
||||
PLAYBACK_BUFFER_MS,
|
||||
REBUFFER_MS
|
||||
)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(dataSourceFactory)
|
||||
)
|
||||
.build()
|
||||
.apply {
|
||||
addListener(this@PlayerService)
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
false // НЕ обрабатываем Audio Focus автоматически - делаем это вручную
|
||||
)
|
||||
playWhenReady = true
|
||||
setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
||||
trackSelectionParameters = trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setForceHighestSupportedBitrate(true)
|
||||
.build()
|
||||
}
|
||||
Timber.tag("PlayerService").i("ExoPlayer initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error setting up player: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMediaSession() {
|
||||
mediaSession = MediaSession.Builder(this, exoPlayer!!)
|
||||
.setSessionActivity(getSessionActivity())
|
||||
.setCallback(MediaSessionCallback())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun setupNotificationManager() {
|
||||
playerNotificationManager = PlayerNotificationManager.Builder(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
.setMediaDescriptionAdapter(NotificationMediaAdapter())
|
||||
.setChannelNameResourceId(R.string.notification_info)
|
||||
.setChannelDescriptionResourceId(R.string.default_notification_channel_name)
|
||||
.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
ongoing: Boolean
|
||||
) {
|
||||
// Обновляем уведомление, если сервис уже в foreground режиме
|
||||
if (ongoing && isForegroundService) {
|
||||
startForeground(notificationId, notification)
|
||||
} else if (!ongoing) {
|
||||
stopForeground(false)
|
||||
isForegroundService = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
||||
stopSelf()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
.apply {
|
||||
setPlayer(exoPlayer)
|
||||
setColorized(true)
|
||||
setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val notificationListener = object : PlayerNotificationManager.NotificationListener {
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
ongoing: Boolean
|
||||
) {
|
||||
if (ongoing && !isForegroundService) {
|
||||
startForeground(notificationId, notification)
|
||||
isForegroundService = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNetworkMonitor() {
|
||||
networkMonitor.addListener(object : NetworkMonitor.Listener {
|
||||
override fun onNetworkAvailable() {
|
||||
// Post to the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
exoPlayer?.prepare()
|
||||
exoPlayer?.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNetworkLost() {
|
||||
// Post to the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
exoPlayer?.pause()
|
||||
}
|
||||
}
|
||||
})
|
||||
networkMonitor.start()
|
||||
}
|
||||
|
||||
|
||||
private fun getSessionActivity(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
|
||||
val pendingIntentFlags = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
else -> {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
}
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
pendingIntentFlags
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return if (::mediaSession.isInitialized) mediaSession else null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
// Убеждаемся, что сервис в foreground режиме при каждом вызове startForegroundService()
|
||||
if (!isForegroundService) {
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification())
|
||||
isForegroundService = true
|
||||
}
|
||||
|
||||
intent?.let {
|
||||
when (it.action) {
|
||||
Keys.ACTION_PLAY_STREAM -> {
|
||||
val streamUri = it.getStringExtra(Keys.EXTRA_STREAM_URI)
|
||||
val stationName = it.getStringExtra(Keys.EXTRA_STATION_NAME)
|
||||
val stationImage = it.getStringExtra(Keys.EXTRA_STATION_IMAGE)
|
||||
val stationUuid = it.getStringExtra(Keys.EXTRA_STATION_UUID)
|
||||
|
||||
Timber.tag("PlayerService").i("Received ACTION_PLAY_STREAM: name=$stationName, uri=$streamUri, uuid=$stationUuid")
|
||||
|
||||
if (!streamUri.isNullOrBlank()) {
|
||||
val artUri = if (!stationImage.isNullOrBlank()) {
|
||||
android.net.Uri.parse(stationImage)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
playRadio(
|
||||
url = streamUri,
|
||||
title = stationName ?: "Radio Station",
|
||||
artist = getString(R.string.app_name),
|
||||
artUri = artUri,
|
||||
stationUuid = stationUuid
|
||||
)
|
||||
} else {
|
||||
Timber.tag("PlayerService").e("Stream URI is null or blank")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Timber.tag("PlayerService").i("Task removed, isPlaying: ${exoPlayer?.isPlaying}")
|
||||
|
||||
// Останавливаем сервис только если не воспроизводится
|
||||
if (exoPlayer?.isPlaying != true) {
|
||||
Timber.tag("PlayerService").i("Stopping service - not playing")
|
||||
stopServiceGracefully()
|
||||
} else {
|
||||
Timber.tag("PlayerService").i("Keeping service alive - still playing")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseResources()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun releaseResources() {
|
||||
try {
|
||||
retryHandler.removeCallbacksAndMessages(null)
|
||||
retryAttempts = 0
|
||||
networkMonitor.stop()
|
||||
|
||||
// Освобождаем Audio Focus
|
||||
if (hasAudioFocus) {
|
||||
audioManager.abandonAudioFocus(audioFocusRequest)
|
||||
hasAudioFocus = false
|
||||
Timber.tag("PlayerService").i("Audio focus abandoned")
|
||||
}
|
||||
|
||||
if (::mediaSession.isInitialized) {
|
||||
mediaSession.release()
|
||||
}
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
|
||||
if (::playerNotificationManager.isInitialized) {
|
||||
playerNotificationManager.setPlayer(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error releasing resources: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private inner class MediaSessionCallback : MediaSession.Callback {
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val baseResult = super.onConnect(session, controller)
|
||||
val customCommands = listOf(
|
||||
SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY),
|
||||
SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY),
|
||||
SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY),
|
||||
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY)
|
||||
)
|
||||
val newSessionCommands = baseResult.availableSessionCommands
|
||||
.buildUpon()
|
||||
.apply { customCommands.forEach { add(it) } }
|
||||
.build()
|
||||
return MediaSession.ConnectionResult.accept(
|
||||
newSessionCommands,
|
||||
baseResult.availablePlayerCommands
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
when (customCommand.customAction) {
|
||||
"play" -> {
|
||||
if (isAppInForeground()) {
|
||||
// Запрашиваем Audio Focus перед воспроизведением
|
||||
val result = audioManager.requestAudioFocus(
|
||||
audioFocusRequest,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
hasAudioFocus = true
|
||||
exoPlayer?.play()
|
||||
Timber.tag("PlayerService").i("Play command: Audio focus granted, playing")
|
||||
} else {
|
||||
Timber.tag("PlayerService").w("Play command: Audio focus denied")
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PlayerService").w("Невозможно воспроизвести из фонового режима")
|
||||
}
|
||||
}
|
||||
"pause" -> handlePauseCommand()
|
||||
"stop" -> stopServiceGracefully()
|
||||
Keys.CMD_REQUEST_METADATA_HISTORY -> {
|
||||
val metadataHistory = PreferencesHelper.loadMetadataHistory()
|
||||
val resultBundle = Bundle().apply {
|
||||
putStringArrayList(Keys.EXTRA_METADATA_HISTORY, ArrayList(metadataHistory))
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS, resultBundle))
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
private fun handlePauseCommand() {
|
||||
exoPlayer?.pause()
|
||||
|
||||
// Освобождаем Audio Focus при паузе
|
||||
if (hasAudioFocus) {
|
||||
audioManager.abandonAudioFocus(audioFocusRequest)
|
||||
hasAudioFocus = false
|
||||
Timber.tag("PlayerService").i("Audio focus abandoned on pause")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false)
|
||||
}
|
||||
isForegroundService = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
Timber.tag("PlayerService").i("onIsPlayingChanged: $isPlaying")
|
||||
|
||||
if (!isPlaying) {
|
||||
// Когда воспроизведение останавливается, освобождаем Audio Focus
|
||||
if (hasAudioFocus && exoPlayer?.playWhenReady == false) {
|
||||
audioManager.abandonAudioFocus(audioFocusRequest)
|
||||
hasAudioFocus = false
|
||||
Timber.tag("PlayerService").i("Audio focus abandoned - playback stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
try {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
Timber.tag("PlayerService").i("Playback state: READY")
|
||||
retryAttempts = 0 // Reset retry counter on successful playback
|
||||
handlePlaybackReady()
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
Timber.tag("PlayerService").i("Playback state: ENDED")
|
||||
handlePlaybackEnd()
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
Timber.tag("PlayerService").i("Playback state: IDLE")
|
||||
// Останавливаем сервис если плеер не должен воспроизводить
|
||||
if (exoPlayer?.playWhenReady == false) {
|
||||
// Освобождаем Audio Focus
|
||||
if (hasAudioFocus) {
|
||||
audioManager.abandonAudioFocus(audioFocusRequest)
|
||||
hasAudioFocus = false
|
||||
Timber.tag("PlayerService").i("Audio focus abandoned on idle")
|
||||
}
|
||||
stopServiceGracefully()
|
||||
}
|
||||
}
|
||||
Player.STATE_BUFFERING -> {
|
||||
Timber.tag("PlayerService").i("Playback state: BUFFERING")
|
||||
handleBufferingState()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error in onPlaybackStateChanged: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Timber.tag("PlayerService").e("Playback error: ${error.errorCodeName} - ${error.message}")
|
||||
when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> {
|
||||
Timber.tag("PlayerService").e("Network connection failed")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
||||
Timber.tag("PlayerService").e("Bad HTTP status: ${error.message}")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
|
||||
Timber.tag("PlayerService").e("Invalid HTTP content type")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
PlaybackException.ERROR_CODE_TIMEOUT -> {
|
||||
Timber.tag("PlayerService").e("Playback timeout")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
||||
Timber.tag("PlayerService").e("Unspecified IO error")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
|
||||
Timber.tag("PlayerService").e("Malformed stream format")
|
||||
stopServiceGracefully()
|
||||
}
|
||||
else -> {
|
||||
Timber.tag("PlayerService").e("Unknown playback error: ${error.errorCode}")
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: Metadata) {
|
||||
super.onMetadata(metadata)
|
||||
try {
|
||||
if (metadata.length() > 0) {
|
||||
val metadataString = AudioHelper.getMetadataString(metadata)
|
||||
if (metadataString.isNotEmpty()) {
|
||||
Timber.tag("PlayerService").i("Metadata received: $metadataString")
|
||||
|
||||
// Сохраняем метаданные в историю
|
||||
val metadataHistory = PreferencesHelper.loadMetadataHistory()
|
||||
|
||||
// Добавляем только если это новые метаданные
|
||||
if (metadataHistory.isEmpty() || metadataHistory.last() != metadataString) {
|
||||
metadataHistory.add(metadataString)
|
||||
|
||||
// Ограничиваем размер истории
|
||||
while (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
|
||||
metadataHistory.removeAt(0)
|
||||
}
|
||||
|
||||
PreferencesHelper.saveMetadataHistory(metadataHistory)
|
||||
Timber.tag("PlayerService").i("Metadata saved to history")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error processing metadata: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackReady() {
|
||||
optimizePowerUsage()
|
||||
}
|
||||
|
||||
private fun handleBufferingState() {
|
||||
exoPlayer?.playWhenReady = true
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.notification_info),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.default_notification_channel_name)
|
||||
setShowBadge(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createInitialNotification(): Notification {
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.notification_info))
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentIntent(getSessionActivity())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun playRadio(url: String, title: String, artist: String, artUri: Uri?, stationUuid: String? = null) {
|
||||
try {
|
||||
Timber.tag("PlayerService").i("=== playRadio called ===")
|
||||
Timber.tag("PlayerService").i("Title: $title, URL: $url, UUID: $stationUuid")
|
||||
|
||||
if (url.isBlank()) {
|
||||
Timber.tag("PlayerService").e("Invalid URL: URL is blank")
|
||||
return
|
||||
}
|
||||
|
||||
exoPlayer?.apply {
|
||||
// Применяем индивидуальную громкость ДО начала воспроизведения
|
||||
val targetVolume = if (!stationUuid.isNullOrBlank()) {
|
||||
val stationVolume = VolumeSettingsHelper.getVolume(this@PlayerService, stationUuid)
|
||||
Timber.tag("PlayerService").i("Station has custom volume: $stationVolume (${(stationVolume * 100).toInt()}%)")
|
||||
stationVolume
|
||||
} else {
|
||||
Timber.tag("PlayerService").i("No UUID provided, using default volume: 1.0")
|
||||
1.0f
|
||||
}
|
||||
|
||||
// Устанавливаем громкость перед подготовкой
|
||||
volume = targetVolume
|
||||
Timber.tag("PlayerService").i("Volume set to: $targetVolume before playback")
|
||||
|
||||
// Сбрасываем флаги Audio Focus для нового воспроизведения
|
||||
resumeOnFocusGain = false
|
||||
playbackDelayed = false
|
||||
|
||||
// Останавливаем текущее воспроизведение
|
||||
stop()
|
||||
clearMediaItems()
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artUri)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
setMediaItem(mediaItem)
|
||||
|
||||
// Подтверждаем, что громкость установлена
|
||||
Timber.tag("PlayerService").i("Current player volume before prepare: $volume")
|
||||
|
||||
// Запрашиваем Audio Focus ПЕРЕД prepare и play
|
||||
requestAudioFocusAndPlay()
|
||||
} ?: run {
|
||||
Timber.tag("PlayerService").e("ExoPlayer is null, cannot play radio")
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.tag("PlayerService").e("Invalid URL format: ${e.message}")
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.tag("PlayerService").e("Player in invalid state: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error playing radio: ${e.message}")
|
||||
handlePlaybackError(PlaybackException("Error playing radio", e, PlaybackException.ERROR_CODE_UNSPECIFIED))
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAudioFocusAndPlay() {
|
||||
Timber.tag("PlayerService").i("Requesting audio focus...")
|
||||
|
||||
val result = audioManager.requestAudioFocus(
|
||||
audioFocusRequest,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
|
||||
Timber.tag("PlayerService").i("Audio focus request result: $result")
|
||||
|
||||
when (result) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
||||
Timber.tag("PlayerService").i("Audio focus GRANTED - starting playback")
|
||||
hasAudioFocus = true
|
||||
playbackDelayed = false
|
||||
|
||||
// Теперь можем безопасно начать воспроизведение
|
||||
exoPlayer?.let { player ->
|
||||
player.prepare()
|
||||
player.play()
|
||||
Timber.tag("PlayerService").i("Playback started with audio focus")
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
||||
Timber.tag("PlayerService").w("Audio focus FAILED - cannot play")
|
||||
hasAudioFocus = false
|
||||
playbackDelayed = false
|
||||
Toast.makeText(this, getString(R.string.notification_play), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
||||
Timber.tag("PlayerService").i("Audio focus DELAYED - will play when granted")
|
||||
hasAudioFocus = false
|
||||
playbackDelayed = true
|
||||
|
||||
// prepare() вызовем сейчас, play() будет вызван в onAudioFocusChange
|
||||
exoPlayer?.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopServiceGracefully() {
|
||||
Timber.tag("PlayerService").i("Stopping service gracefully")
|
||||
|
||||
// Освобождаем Audio Focus перед остановкой
|
||||
if (hasAudioFocus) {
|
||||
audioManager.abandonAudioFocus(audioFocusRequest)
|
||||
hasAudioFocus = false
|
||||
Timber.tag("PlayerService").i("Audio focus abandoned on stop")
|
||||
}
|
||||
|
||||
exoPlayer?.stop()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun optimizePowerUsage() {
|
||||
exoPlayer?.setWakeMode(
|
||||
if (isCharging(this)) C.WAKE_MODE_NETWORK
|
||||
else C.WAKE_MODE_LOCAL
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePlaybackEnd() {
|
||||
stopServiceGracefully()
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||
val packageName = packageName
|
||||
return appProcesses.any { process ->
|
||||
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
|
||||
process.processName == packageName
|
||||
}
|
||||
}
|
||||
|
||||
fun isCharging(context: Context): Boolean {
|
||||
val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
|
||||
val batteryStatus: Intent? = context.registerReceiver(null, intentFilter)
|
||||
|
||||
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
}
|
||||
|
||||
private var retryAttempts = 0
|
||||
private val maxRetryAttempts = 3
|
||||
private val retryHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private fun handlePlaybackError(error: PlaybackException) {
|
||||
Timber.tag("PlayerService").e("Playback error: ${error.errorCodeName} - ${error.message}")
|
||||
when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
||||
if (retryAttempts < maxRetryAttempts) {
|
||||
retryPlayback()
|
||||
} else {
|
||||
Timber.tag("PlayerService").e("Max retry attempts reached, stopping playback")
|
||||
retryAttempts = 0
|
||||
stopServiceGracefully()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
retryAttempts = 0
|
||||
stopServiceGracefully()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun retryPlayback() {
|
||||
retryAttempts++
|
||||
val delayMillis = (retryAttempts * 2000L) // Exponential backoff: 2s, 4s, 6s
|
||||
Timber.tag("PlayerService").i("Retrying playback (attempt $retryAttempts/$maxRetryAttempts) in ${delayMillis}ms")
|
||||
|
||||
retryHandler.postDelayed({
|
||||
try {
|
||||
exoPlayer?.let { player ->
|
||||
if (player.playbackState == Player.STATE_IDLE ||
|
||||
player.playbackState == Player.STATE_ENDED) {
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PlayerService").e("Error during retry: ${e.message}")
|
||||
retryAttempts = 0
|
||||
stopServiceGracefully()
|
||||
}
|
||||
}, delayMillis)
|
||||
}
|
||||
|
||||
private inner class NotificationMediaAdapter : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
override fun getCurrentContentTitle(player: Player): CharSequence {
|
||||
return player.mediaMetadata.title ?: getString(R.string.app_name)
|
||||
}
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? {
|
||||
return getSessionActivity()
|
||||
}
|
||||
|
||||
override fun getCurrentContentText(player: Player): CharSequence? {
|
||||
return player.mediaMetadata.artist ?: getString(R.string.sample_text_station_metadata)
|
||||
}
|
||||
|
||||
override fun getCurrentLargeIcon(
|
||||
player: Player,
|
||||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
val imageUri = player.mediaMetadata.artworkUri
|
||||
return if (imageUri != null) {
|
||||
loadBitmapAsync(imageUri.toString(), callback)
|
||||
null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBitmapAsync(url: String, callback: PlayerNotificationManager.BitmapCallback) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val bitmap = Glide.with(this@PlayerService)
|
||||
.asBitmap()
|
||||
.load(url)
|
||||
.apply(RequestOptions().timeout(10000))
|
||||
.submit()
|
||||
.get()
|
||||
callback.onBitmap(bitmap)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("Player").v("Playback error: notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ID = 2031
|
||||
const val NOTIFICATION_CHANNEL_ID = "Проигрование"
|
||||
private const val MIN_BUFFER_MS = 5000
|
||||
private const val MAX_BUFFER_MS = 10000
|
||||
private const val PLAYBACK_BUFFER_MS = 2000
|
||||
private const val REBUFFER_MS = 5000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.yaros.RadioUrl
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.yaros.RadioUrl.helpers.AdManager
|
||||
import com.yaros.RadioUrl.helpers.GoogleServicesHelper
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper.initPreferences
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import android.app.Application
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class URLRadio : Application() {
|
||||
var firebaseAnalytics: Any? = null // Nullable для опциональной поддержки Firebase
|
||||
private val TAG: String = URLRadio::class.java.simpleName
|
||||
lateinit var adManager: AdManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.tag(TAG).v("URLRadio application started.")
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
// Инициализация Firebase с runtime-детекцией
|
||||
initFirebaseWithFallback()
|
||||
initPreferences()
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
adManager = AdManager(this)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
|
||||
NetworkHelper.initialize(this)
|
||||
|
||||
// Вывод статуса сервисов
|
||||
Timber.tag(TAG).d(GoogleServicesHelper.getServicesStatus(this))
|
||||
}
|
||||
|
||||
private fun initFirebaseWithFallback() {
|
||||
if (!GoogleServicesHelper.isFirebaseAvailable()) {
|
||||
Timber.tag(TAG).w("Firebase services are unavailable - running in FOSS mode")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Безопасная инициализация Firebase
|
||||
if (GoogleServicesHelper.initFirebaseSafely(this)) {
|
||||
initFirebaseComponents()
|
||||
} else {
|
||||
Timber.tag(TAG).w("Firebase initialization failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Firebase initialization error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initFirebaseComponents() {
|
||||
try {
|
||||
// Инициализация аналитики
|
||||
firebaseAnalytics = GoogleServicesHelper.getFirebaseAnalytics(this)
|
||||
if (firebaseAnalytics != null) {
|
||||
Timber.tag(TAG).d("Firebase Analytics initialized successfully")
|
||||
}
|
||||
// Инициализация FCM с обработкой ошибок
|
||||
initFCM()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Firebase component initialization failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initFCM() {
|
||||
if (!GoogleServicesHelper.isFirebaseAvailable()) return
|
||||
|
||||
GoogleServicesHelper.getFirebaseMessagingToken(
|
||||
onSuccess = { token ->
|
||||
Timber.tag("FCM").d("FCM Token: $token")
|
||||
},
|
||||
onFailure = { exception ->
|
||||
Timber.tag("FCM").w(exception, "Failed to get FCM token")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
super.onStart(owner)
|
||||
Keys.isForeground = true
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
Keys.isForeground = false
|
||||
Keys.isPausedFromClick = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка доступности Firebase (делегируется в GoogleServicesHelper)
|
||||
*/
|
||||
fun isFirebaseAvailable(): Boolean {
|
||||
return GoogleServicesHelper.isFirebaseAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка доступности Google Play Services
|
||||
*/
|
||||
fun isGooglePlayServicesAvailable(): Boolean {
|
||||
return GoogleServicesHelper.isGooglePlayServicesAvailable(this)
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
Timber.tag(TAG).w("Low memory state detected")
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
Timber.tag(TAG).w("Trim memory level: $level")
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
Timber.tag(TAG).v("URLRadio application terminated.")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yaros.RadioUrl.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
|
||||
class CategoryAdapter(
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (Category) -> Unit
|
||||
) : ListAdapter<Category, CategoryAdapter.ViewHolder>(CategoryDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_category, parent, false)
|
||||
return ViewHolder(view, imageLoader, onItemClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (Category) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val categoryImage: ImageView = itemView.findViewById(R.id.categoryImage)
|
||||
private val categoryName: TextView = itemView.findViewById(R.id.categoryName)
|
||||
|
||||
fun bind(category: Category) {
|
||||
categoryName.text = category.name
|
||||
imageLoader(categoryImage, category.image ?: "")
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onItemClick(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CategoryDiffCallback : DiffUtil.ItemCallback<Category>() {
|
||||
override fun areItemsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yaros.RadioUrl.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
|
||||
class CategoryGridAdapter(
|
||||
private val onItemClick: (Category) -> Unit
|
||||
) : ListAdapter<Category, CategoryGridAdapter.ViewHolder>(CategoryDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_category_grid, parent, false)
|
||||
return ViewHolder(view, onItemClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val onItemClick: (Category) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val categoryImage: ImageView = itemView.findViewById(R.id.categoryImage)
|
||||
private val categoryName: TextView = itemView.findViewById(R.id.categoryName)
|
||||
private val stationCount: TextView = itemView.findViewById(R.id.stationCount)
|
||||
|
||||
fun bind(category: Category) {
|
||||
categoryName.text = category.name
|
||||
stationCount.text = "${category.stationsCount} станций"
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onItemClick(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CategoryDiffCallback : DiffUtil.ItemCallback<Category>() {
|
||||
override fun areItemsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yaros.RadioUrl.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
|
||||
|
||||
class NewsAdapter(
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (NewsItem) -> Unit
|
||||
) : ListAdapter<NewsItem, NewsAdapter.ViewHolder>(NewsDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_news, parent, false)
|
||||
return ViewHolder(view, imageLoader, onItemClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (NewsItem) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val newsImage: ImageView = itemView.findViewById(R.id.newsImage)
|
||||
private val newsTitle: TextView = itemView.findViewById(R.id.newsTitle)
|
||||
private val newsDescription: TextView = itemView.findViewById(R.id.newsDescription)
|
||||
|
||||
fun bind(newsItem: NewsItem) {
|
||||
newsTitle.text = newsItem.title
|
||||
newsDescription.text = newsItem.content
|
||||
imageLoader(newsImage, newsItem.image ?: "")
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onItemClick(newsItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NewsDiffCallback : DiffUtil.ItemCallback<NewsItem>() {
|
||||
override fun areItemsTheSame(oldItem: NewsItem, newItem: NewsItem): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: NewsItem, newItem: NewsItem): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.yaros.RadioUrl.adapters
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.slider.Slider
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
|
||||
class RadioStationAdapter(
|
||||
private val imageLoader: ((ImageView, String) -> Unit)? = null,
|
||||
private val onItemClick: (Station) -> Unit,
|
||||
private val onPlayClick: ((Station) -> Unit)? = null,
|
||||
private val onAddToCollectionClick: ((Station) -> Unit)? = null,
|
||||
private val onVolumeClick: ((Station) -> Unit)? = null
|
||||
) : ListAdapter<Station, RadioStationAdapter.StationViewHolder>(StationDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_radio_station, parent, false)
|
||||
return StationViewHolder(view, imageLoader, onItemClick, onPlayClick, onAddToCollectionClick, onVolumeClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: StationViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class StationViewHolder(
|
||||
itemView: View,
|
||||
private val imageLoader: ((ImageView, String) -> Unit)?,
|
||||
private val onItemClick: (Station) -> Unit,
|
||||
private val onPlayClick: ((Station) -> Unit)?,
|
||||
private val onAddToCollectionClick: ((Station) -> Unit)?,
|
||||
private val onVolumeClick: ((Station) -> Unit)?
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val stationImage: ImageView = itemView.findViewById(R.id.stationImage)
|
||||
private val stationNameText: TextView = itemView.findViewById(R.id.stationNameText)
|
||||
private val stationFrequencyText: TextView = itemView.findViewById(R.id.stationFrequencyText)
|
||||
private val playIndicator: View = itemView.findViewById(R.id.playIndicator)
|
||||
private val playButton: ImageView = itemView.findViewById(R.id.playButton)
|
||||
private val addToCollectionButton: ImageView = itemView.findViewById(R.id.addToCollectionButton)
|
||||
private val volumeButton: ImageView = itemView.findViewById(R.id.volumeButton)
|
||||
|
||||
fun bind(station: Station) {
|
||||
stationNameText.text = station.name
|
||||
stationFrequencyText.text = station.url
|
||||
|
||||
// Load image if imageLoader is provided
|
||||
imageLoader?.invoke(stationImage, station.image ?: "")
|
||||
|
||||
// Set click listener
|
||||
itemView.setOnClickListener {
|
||||
onItemClick(station)
|
||||
}
|
||||
|
||||
// Play button click listener
|
||||
playButton.setOnClickListener {
|
||||
onPlayClick?.invoke(station)
|
||||
}
|
||||
|
||||
// Add to collection button click listener
|
||||
addToCollectionButton.setOnClickListener {
|
||||
onAddToCollectionClick?.invoke(station)
|
||||
}
|
||||
|
||||
// Volume button click listener
|
||||
volumeButton.setOnClickListener {
|
||||
onVolumeClick?.invoke(station)
|
||||
}
|
||||
|
||||
// Show/hide play indicator based on playing state
|
||||
playIndicator.visibility = if (station.isPlaying) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private class StationDiffCallback : DiffUtil.ItemCallback<Station>() {
|
||||
override fun areItemsTheSame(oldItem: Station, newItem: Station): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Station, newItem: Station): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yaros.RadioUrl.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
|
||||
class RecommendedStationAdapter(
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (Station) -> Unit
|
||||
) : ListAdapter<Station, RecommendedStationAdapter.ViewHolder>(StationDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_recommended_station, parent, false)
|
||||
return ViewHolder(view, imageLoader, onItemClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val imageLoader: (ImageView, String) -> Unit,
|
||||
private val onItemClick: (Station) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val stationImage: ImageView = itemView.findViewById(R.id.stationImage)
|
||||
private val stationName: TextView = itemView.findViewById(R.id.stationName)
|
||||
|
||||
fun bind(station: Station) {
|
||||
stationName.text = station.name
|
||||
imageLoader(stationImage, station.image ?: "")
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onItemClick(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class StationDiffCallback : DiffUtil.ItemCallback<Station>() {
|
||||
override fun areItemsTheSame(oldItem: Station, newItem: Station): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Station, newItem: Station): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface
|
||||
|
||||
import com.yaros.RadioUrl.BuildConfig
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
private val okHttpClient = NetworkHelper.createSecureOkHttpClient()
|
||||
.newBuilder()
|
||||
.addInterceptor(createApiKeyInterceptor())
|
||||
.addInterceptor(createLoggingInterceptor())
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun createApiKeyInterceptor() = Interceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
val newUrl = originalRequest.url.newBuilder()
|
||||
.addQueryParameter("api_key", BuildConfig.API_KEY)
|
||||
.build()
|
||||
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||
}
|
||||
|
||||
private fun createLoggingInterceptor() = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
|
||||
val apiService: ApiService by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(ApiConstants.BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(ApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface
|
||||
|
||||
import android.util.Log
|
||||
import com.yaros.RadioUrl.core.APIInterface.ApiService
|
||||
import com.yaros.RadioUrl.core.APIInterface.HomeResponse
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class ApiRepository(private val apiService: ApiService) {
|
||||
|
||||
suspend fun getHomeData(): NetworkResult<HomeResponse> {
|
||||
return handleApiResponse { apiService.getHome() }
|
||||
}
|
||||
|
||||
suspend fun getRecentRadio(): NetworkResult<List<Station>> {
|
||||
return handleApiResponse { apiService.getRecentRadio() }
|
||||
}
|
||||
|
||||
suspend fun getCategories(): NetworkResult<List<Category>> {
|
||||
return handleApiResponse { apiService.getCategoryIndex() }
|
||||
}
|
||||
|
||||
suspend fun getCategoryDetail(categoryId: Int, filter: String? = null): NetworkResult<List<Station>> {
|
||||
return handleApiResponse {
|
||||
apiService.getCategoryDetail(
|
||||
categoryId = categoryId,
|
||||
filter = filter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): NetworkResult<List<Station>> {
|
||||
return handleApiResponse {
|
||||
apiService.getSearchResults(query = query) // Явное указание имени параметра
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchRTL(query: String): NetworkResult<List<Station>> {
|
||||
return handleApiResponse {
|
||||
apiService.getSearchResultsRTL(query = query) // Явное указание имени параметра
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPrivacyPolicy(): NetworkResult<String> {
|
||||
return handleApiResponse { apiService.getPrivacyPolicy() }
|
||||
}
|
||||
|
||||
suspend fun getAppSettings(): NetworkResult<Map<String, String>> {
|
||||
return handleApiResponse { apiService.getSettings() }
|
||||
}
|
||||
|
||||
suspend fun getNewSettings(): NetworkResult<Map<String, String>> {
|
||||
return handleApiResponse { apiService.getNewSettings() }
|
||||
}
|
||||
|
||||
suspend fun getSocialLinks(): NetworkResult<List<Any>> {
|
||||
return handleApiResponse { apiService.getSocial() }
|
||||
}
|
||||
|
||||
suspend fun updateStationViews(stationId: Int): NetworkResult<Unit> {
|
||||
return handleApiResponse {
|
||||
apiService.updateView(stationId = stationId) // Явное указание имени параметра
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkServerConnection(): NetworkResult<String> {
|
||||
return handleApiResponse { apiService.checkConnection() }
|
||||
}
|
||||
|
||||
private suspend inline fun <T> handleApiResponse(crossinline request: suspend () -> Response<T>): NetworkResult<T> {
|
||||
return try {
|
||||
val response = request.invoke()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
NetworkResult.Success(it)
|
||||
} ?: NetworkResult.Error("Response body is empty")
|
||||
} else {
|
||||
NetworkResult.Error("API error: ${response.code()} - ${response.message()}")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
NetworkResult.Error("Network error: ${e.localizedMessage}")
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Error("Unexpected error: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NetworkResult<out T> {
|
||||
data class Success<out T>(val data: T) : NetworkResult<T>()
|
||||
data class Error(val message: String) : NetworkResult<Nothing>()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface
|
||||
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface ApiService {
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getHome(@Query("x") x: String = "get_home", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<HomeResponse>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getRecentRadio(@Query("x") x: String = "get_radios", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Station>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getCategoryIndex(@Query("x") x: String = "get_categories", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Category>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getCategoryDetail(@Query("x") x: String = "get_category_detail", @Query("category_id") categoryId: Int, @Query("filter") filter: String? = null, @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Station>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getSearchResults(@Query("x") x: String = "search", @Query("q") query: String, @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Station>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getSearchResultsRTL(@Query("x") x: String = "search_rtl", @Query("q") query: String, @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Station>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getPrivacyPolicy(@Query("x") x: String = "privacy_policy", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<String>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getSettings(@Query("x") x: String = "settings", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<Map<String, String>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getNewSettings(@Query("x") x: String = "setting", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<Map<String, String>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun getSocial(@Query("x") x: String = "social", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<List<Any>>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun updateView(@Query("x") x: String = "update_view", @Query("station_id") stationId: Int, @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<Unit>
|
||||
|
||||
@GET("api.php")
|
||||
suspend fun checkConnection(@Query("x") x: String = "check_connection", @Query("api_key") apiKey: String = "cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"): Response<String>
|
||||
|
||||
}
|
||||
|
||||
data class HomeResponse(
|
||||
@SerializedName("news") val news: List<NewsItem>?,
|
||||
@SerializedName("favorites") val favorites: List<Station>?
|
||||
)
|
||||
|
||||
object ApiConstants {
|
||||
const val BASE_URL = Keys.SWH_ADDRESS
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Category(
|
||||
@SerializedName("cid") val id: Int,
|
||||
@SerializedName("category_name") val name: String,
|
||||
@SerializedName("radio_count") val stationsCount: Int,
|
||||
@SerializedName("category_image") val image: String?
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class NewsItem(
|
||||
@SerializedName("id") val id: Int,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("image") val image: String?,
|
||||
@SerializedName("date") val date: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Social(
|
||||
@SerializedName("id") val id: Int,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("image") val image: String?
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Station(
|
||||
@SerializedName("id") var id: String,
|
||||
@SerializedName("name") var name: String,
|
||||
@SerializedName("url") var streamUrl: String,
|
||||
@SerializedName("image") var image: String?,
|
||||
@SerializedName("type") var type: String?,
|
||||
@SerializedName("category") var category: String?,
|
||||
@SerializedName("views") var views: Int
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [RadioEntity::class], version = 1, exportSchema = false)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun get(): DAO
|
||||
|
||||
companion object {
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDb(context: Context): AppDatabase {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"SWRApp.database"
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
return INSTANCE!!
|
||||
}
|
||||
|
||||
fun destroyInstance() {
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface DAO {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertRadio(radio: RadioEntity)
|
||||
|
||||
@Query("DELETE FROM radio WHERE radio_id = :radio_id")
|
||||
fun deleteRadio(radio_id: String)
|
||||
|
||||
@Query("DELETE FROM radio")
|
||||
fun deleteAllRadio()
|
||||
|
||||
@Query("SELECT * FROM radio ORDER BY saved_date DESC")
|
||||
fun getAllRadio(): List<RadioEntity>
|
||||
|
||||
@Query("SELECT COUNT(radio_id) FROM radio")
|
||||
fun getRadioCount(): Int?
|
||||
|
||||
@Query("SELECT * FROM radio WHERE radio_id = :radio_id LIMIT 1")
|
||||
fun getRadio(radio_id: String): RadioEntity?
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.yaros.RadioUrl.core.APIInterface.database
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Station
|
||||
|
||||
@Entity(tableName = "radio")
|
||||
data class RadioEntity(
|
||||
@PrimaryKey
|
||||
@Expose
|
||||
@ColumnInfo(name = "radio_id")
|
||||
var id: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "name")
|
||||
var name: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "image")
|
||||
var image: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "url")
|
||||
var streamUrl: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "type")
|
||||
var type: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "views")
|
||||
var views: Int,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "category")
|
||||
var category: String,
|
||||
|
||||
@Expose
|
||||
@ColumnInfo(name = "saved_date")
|
||||
var savedDate: Long = System.currentTimeMillis()
|
||||
) {
|
||||
companion object {
|
||||
fun entity(station: Station): RadioEntity = RadioEntity(
|
||||
id = station.id,
|
||||
name = station.name,
|
||||
image = station.image ?: "",
|
||||
streamUrl = station.streamUrl,
|
||||
type = station.type ?: "",
|
||||
views = station.views,
|
||||
category = station.category ?: "",
|
||||
savedDate = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
fun original(): Station = Station(
|
||||
id = id,
|
||||
name = name,
|
||||
streamUrl = streamUrl,
|
||||
image = image.ifEmpty { null },
|
||||
type = type.ifEmpty { null },
|
||||
category = category.ifEmpty { null },
|
||||
views = views
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yaros.RadioUrl.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@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 {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun deepCopy(): Collection {
|
||||
val stationsCopy: MutableList<Station> = mutableListOf()
|
||||
|
||||
stations.forEach { stationsCopy.add(it.deepCopy()) }
|
||||
return Collection(
|
||||
version = version,
|
||||
stations = stationsCopy,
|
||||
modificationDate = modificationDate
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.yaros.RadioUrl.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
@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(),
|
||||
@Expose var countrycode: String = String(),
|
||||
@Expose var country: String = String(),
|
||||
@Expose var language: String = String(),
|
||||
@Expose var languagecodes: String = String()
|
||||
) : Parcelable {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun getStreamUri(): String {
|
||||
//Log.e("ErrorStreamUris","streamUris: $streamUris, stream: $stream")
|
||||
return if (streamUris.isNotEmpty() && stream in streamUris.indices) {
|
||||
streamUris[stream]
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun isValid(): Boolean {
|
||||
return uuid.isNotEmpty() && name.isNotEmpty() && streamUris.isNotEmpty() && streamUris[stream].isNotEmpty() && modificationDate != Keys.DEFAULT_DATE && streamContent != Keys.MIME_TYPE_UNSUPPORTED
|
||||
}
|
||||
|
||||
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,
|
||||
countrycode = countrycode,
|
||||
language = language,
|
||||
languagecodes = languagecodes,
|
||||
country = country
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package com.yaros.RadioUrl.core.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.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.helpers.CollectionHelper
|
||||
import com.yaros.RadioUrl.helpers.FileHelper
|
||||
import com.yaros.RadioUrl.helpers.ImageHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.ShortcutHelper
|
||||
import com.yaros.RadioUrl.helpers.UiHelper
|
||||
import com.yaros.RadioUrl.helpers.UpdateHelper
|
||||
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
|
||||
import com.google.android.material.slider.Slider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
|
||||
class CollectionAdapter(
|
||||
private val context: Context,
|
||||
private val collectionAdapterListener: CollectionAdapterListener
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), UpdateHelper.UpdateHelperListener {
|
||||
|
||||
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
|
||||
|
||||
interface CollectionAdapterListener {
|
||||
fun onPlayButtonTapped(stationUuid: String)
|
||||
fun onAddNewButtonTapped()
|
||||
fun onChangeImageButtonTapped(stationUuid: String)
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
collectionViewModel =
|
||||
ViewModelProvider(context as AppCompatActivity)[CollectionViewModel::class.java]
|
||||
observeCollectionViewModel(context as LifecycleOwner)
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onDetachedFromRecyclerView(recyclerView)
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
Keys.VIEW_TYPE_ADD_NEW -> {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.card_add_new_station, parent, false)
|
||||
AddNewViewHolder(v)
|
||||
}
|
||||
else -> {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.card_station, parent, false)
|
||||
StationViewHolder(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int) {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
Collections.swap(stationList, fromPosition, toPosition)
|
||||
expandedStationPosition =
|
||||
if (fromPosition == expandedStationPosition) toPosition else expandedStationPosition
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
}
|
||||
|
||||
|
||||
fun onItemDismiss(position: Int) {
|
||||
collection.stations.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
|
||||
|
||||
fun saveCollectionAfterDragDrop() {
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is AddNewViewHolder -> {
|
||||
val addNewViewHolder: AddNewViewHolder = holder
|
||||
addNewViewHolder.addNewStationView.setOnClickListener {
|
||||
collectionAdapterListener.onAddNewButtonTapped()
|
||||
}
|
||||
}
|
||||
|
||||
is StationViewHolder -> {
|
||||
val station: Station = collection.stations[position]
|
||||
val stationViewHolder: StationViewHolder = holder
|
||||
setStarredIcon(stationViewHolder, station)
|
||||
setStationName(stationViewHolder, station)
|
||||
setStationImage(stationViewHolder, station)
|
||||
setStationButtons(stationViewHolder, station)
|
||||
setEditViews(stationViewHolder, station)
|
||||
when (expandedStationPosition) {
|
||||
position -> {
|
||||
stationViewHolder.stationNameView.isVisible = false
|
||||
stationViewHolder.playButtonView.isGone = true
|
||||
stationViewHolder.stationStarredView.isGone = true
|
||||
stationViewHolder.editViews.isVisible = true
|
||||
stationViewHolder.volumeLabel.isVisible = true
|
||||
stationViewHolder.volumeSlider.isVisible = true
|
||||
stationViewHolder.volumeValue.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
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
stationViewHolder.stationNameView.isVisible = true
|
||||
stationViewHolder.stationStarredView.isVisible = station.starred
|
||||
stationViewHolder.editViews.isGone = true
|
||||
stationViewHolder.stationUriEditView.isGone = true
|
||||
stationViewHolder.volumeLabel.isGone = true
|
||||
stationViewHolder.volumeSlider.isGone = true
|
||||
stationViewHolder.volumeValue.isGone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStationUpdated(
|
||||
collection: Collection,
|
||||
positionPriorUpdate: Int,
|
||||
positionAfterUpdate: Int
|
||||
) {
|
||||
if (positionPriorUpdate != positionAfterUpdate && positionPriorUpdate != -1 && positionAfterUpdate != -1) {
|
||||
notifyItemMoved(positionPriorUpdate, positionAfterUpdate)
|
||||
notifyItemChanged(positionPriorUpdate)
|
||||
}
|
||||
notifyItemChanged(positionAfterUpdate)
|
||||
}
|
||||
|
||||
private fun setStationName(stationViewHolder: StationViewHolder, station: Station) {
|
||||
stationViewHolder.stationNameView.text = station.name
|
||||
}
|
||||
|
||||
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) {}
|
||||
})
|
||||
|
||||
// Настройка слайдера громкости
|
||||
val currentVolume = VolumeSettingsHelper.getVolumePercent(context, station.uuid)
|
||||
|
||||
// Округляем значение до ближайшего кратного 5 для совместимости со stepSize
|
||||
val stepSize = 5
|
||||
val normalizedVolume = ((currentVolume + stepSize / 2) / stepSize) * stepSize
|
||||
val clampedVolume = normalizedVolume.coerceIn(0, 100)
|
||||
|
||||
// Очищаем предыдущие слушатели, чтобы избежать дублирования
|
||||
stationViewHolder.volumeSlider.clearOnChangeListeners()
|
||||
|
||||
// Безопасная установка значения слайдера
|
||||
try {
|
||||
stationViewHolder.volumeSlider.value = clampedVolume.toFloat()
|
||||
stationViewHolder.volumeValue.text = "$clampedVolume%"
|
||||
|
||||
// Если значение было изменено при нормализации, сохраняем новое
|
||||
if (clampedVolume != currentVolume) {
|
||||
VolumeSettingsHelper.setVolumePercent(context, station.uuid, clampedVolume)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
// Если все еще возникает ошибка, устанавливаем значение по умолчанию
|
||||
stationViewHolder.volumeSlider.value = 100f
|
||||
stationViewHolder.volumeValue.text = "100%"
|
||||
VolumeSettingsHelper.setVolumePercent(context, station.uuid, 100)
|
||||
timber.log.Timber.tag("CollectionAdapter").e("Error setting volume slider: ${e.message}, reset to 100%")
|
||||
}
|
||||
|
||||
stationViewHolder.volumeSlider.addOnChangeListener { slider, value, fromUser ->
|
||||
if (fromUser) {
|
||||
val volumePercent = value.toInt()
|
||||
stationViewHolder.volumeValue.text = "$volumePercent%"
|
||||
VolumeSettingsHelper.setVolumePercent(context, station.uuid, volumePercent)
|
||||
|
||||
// Визуальная обратная связь
|
||||
if (volumePercent < 30) {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_orange_dark))
|
||||
} else if (volumePercent < 60) {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_orange_light))
|
||||
} else {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_green_light))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем начальный цвет
|
||||
if (clampedVolume < 30) {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_orange_dark))
|
||||
} else if (clampedVolume < 60) {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_orange_light))
|
||||
} else {
|
||||
stationViewHolder.volumeValue.setTextColor(context.getColor(android.R.color.holo_green_light))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleEditViews(position: Int, stationUuid: String) {
|
||||
when (stationUuid) {
|
||||
expandedStationUuid -> {
|
||||
isExpandedForEdit = false
|
||||
saveStationListExpandedState()
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
else -> {
|
||||
isExpandedForEdit = true
|
||||
val previousExpandedStationPosition: Int = expandedStationPosition
|
||||
if (previousExpandedStationPosition > -1 && previousExpandedStationPosition < collection.stations.size)
|
||||
notifyItemChanged(previousExpandedStationPosition)
|
||||
saveStationListExpandedState(position, stationUuid)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStarredIcon(stationViewHolder: StationViewHolder, station: Station) {
|
||||
when (station.starred) {
|
||||
true -> {
|
||||
if (station.imageColor != -1) {
|
||||
stationViewHolder.stationStarredView.setColorFilter(station.imageColor)
|
||||
}
|
||||
stationViewHolder.stationStarredView.isVisible = true
|
||||
}
|
||||
false -> stationViewHolder.stationStarredView.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStationUriInput(
|
||||
stationViewHolder: StationViewHolder,
|
||||
s: Editable?,
|
||||
streamUri: String
|
||||
) {
|
||||
if (editStationStreamsEnabled) {
|
||||
val input: String = s.toString()
|
||||
if (input == streamUri) {
|
||||
stationViewHolder.saveButton.isEnabled = true
|
||||
} else {
|
||||
stationViewHolder.saveButton.isEnabled = false
|
||||
if (input.startsWith("http")) {
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) {
|
||||
NetworkHelper.detectContentTypeSuspended(input)
|
||||
}
|
||||
val contentType: String =
|
||||
deferred.await().type.lowercase(Locale.getDefault())
|
||||
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)
|
||||
) {
|
||||
withContext(Main) {
|
||||
stationViewHolder.saveButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
onBindViewHolder(holder, position)
|
||||
} else if (holder is StationViewHolder) {
|
||||
collection.stations[holder.adapterPosition]
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (isPositionFooter(position)) {
|
||||
true -> Keys.VIEW_TYPE_ADD_NEW
|
||||
false -> Keys.VIEW_TYPE_STATION
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return collection.stations.size + 1
|
||||
}
|
||||
|
||||
fun removeStation(context: Context, position: Int) {
|
||||
val newCollection = collection.deepCopy()
|
||||
CollectionHelper.deleteStationImages(context, newCollection.stations[position])
|
||||
newCollection.stations.removeAt(position)
|
||||
collection = newCollection
|
||||
notifyItemRemoved(position)
|
||||
CollectionHelper.saveCollection(context, newCollection)
|
||||
}
|
||||
|
||||
fun toggleStarredStation(context: Context, position: Int) {
|
||||
notifyItemChanged(position)
|
||||
val stationUuid: String = collection.stations[position].uuid
|
||||
collection.stations[position].apply { starred = !starred }
|
||||
collection = CollectionHelper.sortCollection(collection)
|
||||
notifyItemMoved(position, CollectionHelper.getStationPosition(collection, stationUuid))
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
private fun saveStation(
|
||||
station: Station,
|
||||
position: Int,
|
||||
stationName: String,
|
||||
streamUri: String
|
||||
) {
|
||||
collection.stations.forEach {
|
||||
if (it.uuid == station.uuid) {
|
||||
if (stationName.isNotEmpty()) {
|
||||
it.name = stationName
|
||||
it.nameManuallySet = true
|
||||
}
|
||||
if (streamUri.isNotEmpty()) {
|
||||
it.streamUris[0] = streamUri
|
||||
}
|
||||
}
|
||||
}
|
||||
collection = CollectionHelper.sortCollection(collection)
|
||||
val newPosition: Int = CollectionHelper.getStationPosition(collection, station.uuid)
|
||||
if (position != newPosition && newPosition != -1) {
|
||||
notifyItemMoved(position, newPosition)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
private fun isPositionFooter(position: Int): Boolean {
|
||||
return position == collection.stations.size
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun updateRecyclerView(oldCollection: Collection, newCollection: Collection) {
|
||||
collection = newCollection
|
||||
if (oldCollection.stations.size == 0 && newCollection.stations.size > 0) {
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
val diffResult =
|
||||
DiffUtil.calculateDiff(CollectionDiffCallback(oldCollection, newCollection), true)
|
||||
diffResult.dispatchUpdatesTo(this@CollectionAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveStationListExpandedState(
|
||||
position: Int = -1,
|
||||
stationStreamUri: String = String()
|
||||
) {
|
||||
expandedStationUuid = stationStreamUri
|
||||
expandedStationPosition = position
|
||||
PreferencesHelper.saveStationListStreamUuid(expandedStationUuid)
|
||||
}
|
||||
|
||||
private fun observeCollectionViewModel(owner: LifecycleOwner) {
|
||||
collectionViewModel.collectionLiveData.observe(owner) { newCollection ->
|
||||
updateRecyclerView(collection, newCollection)
|
||||
}
|
||||
}
|
||||
|
||||
private val sharedPreferenceChangeListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_EDIT_STATIONS -> editStationsEnabled =
|
||||
PreferencesHelper.loadEditStationsEnabled()
|
||||
Keys.PREF_EDIT_STREAMS_URIS -> editStationStreamsEnabled =
|
||||
PreferencesHelper.loadEditStreamUrisEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AddNewViewHolder(listItemAddNewLayout: View) :
|
||||
RecyclerView.ViewHolder(listItemAddNewLayout) {
|
||||
val addNewStationView: ExtendedFloatingActionButton =
|
||||
listItemAddNewLayout.findViewById(R.id.card_add_new_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 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)
|
||||
val volumeLabel: TextView = stationCardLayout.findViewById(R.id.volume_label)
|
||||
val volumeSlider: Slider = stationCardLayout.findViewById(R.id.volume_slider)
|
||||
val volumeValue: TextView = stationCardLayout.findViewById(R.id.volume_value)
|
||||
}
|
||||
|
||||
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]
|
||||
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
|
||||
return FileHelper.getFileSize(
|
||||
context,
|
||||
oldStation.smallImage.toUri()
|
||||
) == FileHelper.getFileSize(context, newStation.smallImage.toUri())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.yaros.RadioUrl.core.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.helpers.FileHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
class CollectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val TAG: String = CollectionViewModel::class.java.simpleName
|
||||
|
||||
val collectionLiveData: MutableLiveData<Collection> = MutableLiveData<Collection>()
|
||||
val collectionSizeLiveData: MutableLiveData<Int> = MutableLiveData<Int>()
|
||||
private var modificationDateViewModel: Date = Date()
|
||||
private var collectionChangedReceiver: BroadcastReceiver
|
||||
|
||||
|
||||
init {
|
||||
loadCollection()
|
||||
collectionChangedReceiver = createCollectionChangedReceiver()
|
||||
LocalBroadcastManager.getInstance(application).registerReceiver(
|
||||
collectionChangedReceiver,
|
||||
IntentFilter(Keys.ACTION_COLLECTION_CHANGED)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
LocalBroadcastManager.getInstance(getApplication())
|
||||
.unregisterReceiver(collectionChangedReceiver)
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
if (date.after(modificationDateViewModel)) {
|
||||
Timber.tag(TAG)
|
||||
.v("CollectionViewModel - reload collection after broadcast received.")
|
||||
loadCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCollection() {
|
||||
Timber.tag(TAG).v("Loading collection of stations from storage")
|
||||
viewModelScope.launch {
|
||||
val collection: Collection = FileHelper.readCollectionSuspended(getApplication())
|
||||
modificationDateViewModel = collection.modificationDate
|
||||
collectionLiveData.value = collection
|
||||
collectionSizeLiveData.value = collection.stations.size
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.yaros.RadioUrl.core.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.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.ui.search.SearchResultAdapter
|
||||
|
||||
class AddStationDialog (
|
||||
private val context: Context,
|
||||
private val stationList: List<Station>,
|
||||
private val listener: AddStationDialogListener
|
||||
) :
|
||||
SearchResultAdapter.SearchResultAdapterListener {
|
||||
|
||||
interface AddStationDialogListener {
|
||||
fun onAddStationDialog(station: Station)
|
||||
}
|
||||
|
||||
private val TAG = AddStationDialog::class.java.simpleName
|
||||
|
||||
private lateinit var dialog: AlertDialog
|
||||
private lateinit var stationSearchResultList: RecyclerView
|
||||
private lateinit var searchResultAdapter: SearchResultAdapter
|
||||
private var station: Station = Station()
|
||||
|
||||
override fun onSearchResultTapped(result: Station) {
|
||||
station = result
|
||||
activateAddButton()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setTitle(R.string.dialog_add_station_title)
|
||||
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val view = inflater.inflate(R.layout.dialog_add_station, null)
|
||||
stationSearchResultList = view.findViewById(R.id.station_list)
|
||||
|
||||
setupRecyclerView(context)
|
||||
|
||||
builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ ->
|
||||
listener.onAddStationDialog(station)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ ->
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
builder.setOnCancelListener {
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
|
||||
builder.setView(view)
|
||||
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override fun activateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
|
||||
}
|
||||
|
||||
override fun deactivateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yaros.RadioUrl.core.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.yaros.RadioUrl.R
|
||||
|
||||
|
||||
class ErrorDialog {
|
||||
|
||||
fun show(
|
||||
context: Context,
|
||||
errorTitle: Int,
|
||||
errorMessage: Int,
|
||||
errorDetails: String = String()
|
||||
) {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setTitle(context.getString(errorTitle))
|
||||
|
||||
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)
|
||||
val errorDetailsLinkView: TextView = view.findViewById(R.id.dialog_details_link)
|
||||
val errorDetailsView: TextView = view.findViewById(R.id.dialog_details)
|
||||
|
||||
builder.setView(view)
|
||||
|
||||
val detailsNotEmpty = errorDetails.isNotEmpty()
|
||||
errorDetailsLinkView.isVisible = detailsNotEmpty
|
||||
|
||||
if (detailsNotEmpty) {
|
||||
errorDetailsView.movementMethod = ScrollingMovementMethod()
|
||||
|
||||
errorDetailsLinkView.setOnClickListener {
|
||||
when (errorDetailsView.visibility) {
|
||||
View.GONE -> errorDetailsView.isVisible = true
|
||||
View.VISIBLE -> errorDetailsView.isGone = true
|
||||
View.INVISIBLE -> {
|
||||
return@setOnClickListener
|
||||
}
|
||||
}
|
||||
}
|
||||
errorDetailsView.text = errorDetails
|
||||
}
|
||||
|
||||
errorMessageView.text = context.getString(errorMessage)
|
||||
|
||||
builder.setPositiveButton(R.string.dialog_generic_button_okay) { _, _ ->
|
||||
}
|
||||
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.yaros.RadioUrl.core.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.ui.search.DirectInputCheck
|
||||
import com.yaros.RadioUrl.ui.search.RadioBrowserResult
|
||||
import com.yaros.RadioUrl.ui.search.RadioBrowserSearch
|
||||
import com.yaros.RadioUrl.ui.search.SearchResultAdapter
|
||||
|
||||
class FindStationDialog (
|
||||
private val context: Context,
|
||||
private val listener: FindStationDialogListener
|
||||
):
|
||||
SearchResultAdapter.SearchResultAdapterListener,
|
||||
RadioBrowserSearch.RadioBrowserSearchListener,
|
||||
DirectInputCheck.DirectInputCheckListener {
|
||||
|
||||
interface FindStationDialogListener {
|
||||
fun onFindStationDialog(station: Station) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
override fun onSearchResultTapped(result: Station) {
|
||||
station = result
|
||||
val imm: InputMethodManager =
|
||||
context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(stationSearchBoxView.windowToken, 0)
|
||||
activateAddButton()
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun show() {
|
||||
radioBrowserSearch = RadioBrowserSearch(this)
|
||||
directInputCheck = DirectInputCheck(this)
|
||||
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setTitle(R.string.dialog_find_station_title)
|
||||
|
||||
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
|
||||
|
||||
setupRecyclerView(context)
|
||||
|
||||
builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ ->
|
||||
listener.onFindStationDialog(station)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ ->
|
||||
radioBrowserSearch.stopSearchRequest()
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
builder.setOnCancelListener {
|
||||
radioBrowserSearch.stopSearchRequest()
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
builder.setView(view)
|
||||
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isAllCaps = true
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).isAllCaps = true
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
private fun handleSearchBoxInput(context: Context, query: String) {
|
||||
when {
|
||||
query.isEmpty() -> {
|
||||
resetLayout(clearAdapter = true)
|
||||
}
|
||||
query.startsWith("http") -> {
|
||||
directInputCheck.checkStationAddress(context, query)
|
||||
}
|
||||
else -> {
|
||||
showProgressIndicator()
|
||||
radioBrowserSearch.searchStation(context, query, Keys.SEARCH_TYPE_BY_KEYWORD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun handleSearchBoxLiveInput(context: Context, query: String) {
|
||||
currentSearchString = query
|
||||
if (query.startsWith("htt")) {
|
||||
directInputCheck.checkStationAddress(context, query)
|
||||
} else if (query.contains(" ") || query.length > 2) {
|
||||
showProgressIndicator()
|
||||
handler.postDelayed({
|
||||
if (currentSearchString == query) radioBrowserSearch.searchStation(
|
||||
context,
|
||||
query,
|
||||
Keys.SEARCH_TYPE_BY_KEYWORD
|
||||
)
|
||||
}, 100)
|
||||
} else if (query.isEmpty()) {
|
||||
resetLayout(clearAdapter = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
private fun resetLayout(clearAdapter: Boolean = false) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
searchResultAdapter.resetSelection(clearAdapter)
|
||||
}
|
||||
|
||||
|
||||
private fun showNoResultsError() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isVisible = true
|
||||
}
|
||||
|
||||
|
||||
private fun showProgressIndicator() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isVisible = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yaros.RadioUrl.core.dialogs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
|
||||
|
||||
class YesNoDialog(private var yesNoDialogListener: YesNoDialogListener) {
|
||||
|
||||
interface YesNoDialogListener {
|
||||
fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var dialog: AlertDialog
|
||||
|
||||
|
||||
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
|
||||
) {
|
||||
show(
|
||||
context,
|
||||
type,
|
||||
title,
|
||||
context.getString(message),
|
||||
yesButton,
|
||||
noButton,
|
||||
payload,
|
||||
payloadString
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setMessage(messageString)
|
||||
if (title != Keys.EMPTY_STRING_RESOURCE) {
|
||||
builder.setTitle(context.getString(title))
|
||||
}
|
||||
|
||||
|
||||
builder.setPositiveButton(yesButton) { _, _ ->
|
||||
yesNoDialogListener.onYesNoDialog(type, true, payload, payloadString)
|
||||
}
|
||||
|
||||
builder.setNegativeButton(noButton) { _, _ ->
|
||||
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
|
||||
}
|
||||
|
||||
builder.setOnCancelListener {
|
||||
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
|
||||
}
|
||||
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yaros.RadioUrl.core.extensions
|
||||
|
||||
fun ArrayList<Long>.copy(): ArrayList<Long> {
|
||||
val copy: ArrayList<Long> = ArrayList()
|
||||
this.forEach { copy.add(it) }
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yaros.RadioUrl.core.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.helpers.CollectionHelper
|
||||
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
|
||||
import timber.log.Timber
|
||||
|
||||
|
||||
fun MediaController.startSleepTimer(timerDurationMillis: Long) {
|
||||
val bundle = Bundle().apply {
|
||||
putLong(Keys.SLEEP_TIMER_DURATION, timerDurationMillis)
|
||||
}
|
||||
sendCustomCommand(SessionCommand(Keys.CMD_START_SLEEP_TIMER, bundle), bundle)
|
||||
}
|
||||
|
||||
|
||||
fun MediaController.cancelSleepTimer() {
|
||||
sendCustomCommand(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY), Bundle.EMPTY)
|
||||
}
|
||||
|
||||
|
||||
fun MediaController.requestSleepTimerRemaining(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY),
|
||||
Bundle.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
|
||||
Bundle.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun MediaController.play(context: Context, station: Station) {
|
||||
Timber.tag("MediaControllerExt").i("=== MediaController.play called ===")
|
||||
Timber.tag("MediaControllerExt").i("Station: ${station.name}, UUID: ${station.uuid}")
|
||||
|
||||
// Получаем индивидуальную громкость станции
|
||||
val stationVolume = VolumeSettingsHelper.getVolume(context, station.uuid)
|
||||
Timber.tag("MediaControllerExt").i("Setting volume to: $stationVolume (${(stationVolume * 100).toInt()}%)")
|
||||
|
||||
stop()
|
||||
clearMediaItems()
|
||||
setMediaItem(CollectionHelper.buildMediaItem(context, station))
|
||||
|
||||
// Устанавливаем громкость перед воспроизведением
|
||||
volume = stationVolume
|
||||
Timber.tag("MediaControllerExt").i("Volume applied: $volume")
|
||||
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
|
||||
fun MediaController.playStreamDirectly(streamUri: String) {
|
||||
sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_PLAY_STREAM, Bundle.EMPTY),
|
||||
bundleOf(Pair(Keys.KEY_STREAM_URI, streamUri))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# Supabase Integration
|
||||
|
||||
## Описание
|
||||
Этот модуль обеспечивает интеграцию с Supabase для получения данных о радиостанциях и категориях.
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблица `Category`
|
||||
- `id` (Int) - ID категории
|
||||
- `category_name` (String) - Название категории
|
||||
- `category_image` (String?) - URL изображения категории
|
||||
- `category_status` (String?) - Статус категории
|
||||
- `featured` (Boolean?) - Избранная категория
|
||||
- `last_update` (String?) - Дата последнего обновления
|
||||
|
||||
### Таблица `Radio`
|
||||
- `id` (Int) - ID радиостанции
|
||||
- `category_id` (Int?) - ID категории (внешний ключ)
|
||||
- `radio_name` (String) - Название радиостанции
|
||||
- `radio_image` (String?) - URL изображения
|
||||
- `radio_url` (String) - URL потока
|
||||
- `radio_status` (String?) - Статус радиостанции
|
||||
- `view_count` (Int) - Количество просмотров
|
||||
- `featured` (Boolean?) - Избранная станция
|
||||
- `type` (String?) - Тип радиостанции
|
||||
- `last_update` (String?) - Дата последнего обновления
|
||||
|
||||
## Использование
|
||||
|
||||
### Базовое использование SupabaseRepository
|
||||
|
||||
```kotlin
|
||||
val repository = SupabaseRepository()
|
||||
|
||||
// Получить все категории
|
||||
val categories = repository.getCategories()
|
||||
|
||||
// Получить все радиостанции
|
||||
val stations = repository.getAllStations()
|
||||
|
||||
// Получить радиостанции по ID категории
|
||||
val stationsByCategory = repository.getStationsByCategory(1)
|
||||
|
||||
// Поиск радиостанций
|
||||
val searchResults = repository.searchStations("BBC")
|
||||
|
||||
// Обновить просмотры
|
||||
repository.updateStationViews("station_id", 100)
|
||||
```
|
||||
|
||||
### Использование SupabaseApiRepository (совместимость с ApiRepository)
|
||||
|
||||
```kotlin
|
||||
val apiRepository = SupabaseApiRepository()
|
||||
|
||||
// Получить категории
|
||||
when (val result = apiRepository.getCategories()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
val categories = result.data
|
||||
// Обработка данных
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
val error = result.message
|
||||
// Обработка ошибки
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Замена существующего ApiRepository
|
||||
|
||||
Чтобы использовать Supabase вместо текущего API:
|
||||
|
||||
1. Замените создание `ApiRepository` на `SupabaseApiRepository`:
|
||||
```kotlin
|
||||
// Было:
|
||||
val repository = ApiRepository(ApiClient.apiService)
|
||||
|
||||
// Стало:
|
||||
val repository = SupabaseApiRepository()
|
||||
```
|
||||
|
||||
2. Методы остаются теми же:
|
||||
- `getCategories()`
|
||||
- `getRecentRadio()`
|
||||
- `getCategoryDetail(categoryId, filter)`
|
||||
- `search(query)`
|
||||
- `searchRTL(query)`
|
||||
- `updateStationViews(stationId)`
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Параметры подключения к Supabase находятся в `SupabaseClient.kt`:
|
||||
- URL: https://emxgttrptqqaywszojxu.supabase.co
|
||||
- Key: sb_publishable_J9s-q-GiOq2g3v5EHmdYrA_IzVzFLVe
|
||||
|
||||
## Зависимости
|
||||
|
||||
Добавлены в `build.gradle`:
|
||||
```gradle
|
||||
implementation platform('io.github.jan-tennert.supabase:bom:3.0.3')
|
||||
implementation 'io.github.jan-tennert.supabase:postgrest-kt'
|
||||
implementation 'io.github.jan-tennert.supabase:realtime-kt'
|
||||
implementation 'io.ktor:ktor-client-android:3.0.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
|
||||
```
|
||||
|
||||
Также добавлен плагин сериализации:
|
||||
```gradle
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.10'
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.yaros.RadioUrl.core.supabase
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
|
||||
import com.yaros.RadioUrl.data.Station as DataStation
|
||||
import com.yaros.RadioUrl.helpers.CacheManager
|
||||
|
||||
/**
|
||||
* Адаптер для совместимости с существующим ApiRepository
|
||||
* Использует Supabase вместо Retrofit API с поддержкой кеширования
|
||||
*/
|
||||
class SupabaseApiRepository(private val context: Context? = null) {
|
||||
private val supabaseRepository = SupabaseRepository()
|
||||
|
||||
init {
|
||||
context?.let {
|
||||
CacheManager.init(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем метод getHomeData для совместимости
|
||||
suspend fun getHomeData(): NetworkResult<HomeResponse> {
|
||||
return try {
|
||||
// Получаем последние радиостанции как "избранные"
|
||||
val stationsResult = supabaseRepository.getAllStations()
|
||||
|
||||
if (stationsResult.isSuccess) {
|
||||
val stations = stationsResult.getOrNull()?.map { apiStation ->
|
||||
DataStation(
|
||||
id = apiStation.id.toIntOrNull() ?: 0,
|
||||
name = apiStation.name,
|
||||
url = apiStation.streamUrl,
|
||||
image = apiStation.image ?: "",
|
||||
category = apiStation.category ?: "",
|
||||
country = "",
|
||||
language = "",
|
||||
isFavorite = false,
|
||||
isPlaying = false,
|
||||
listeners = apiStation.views
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
// Возвращаем HomeResponse с пустыми новостями и станциями
|
||||
NetworkResult.Success(HomeResponse(news = emptyList(), favorites = stations))
|
||||
} else {
|
||||
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error getting home data", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCategories(): NetworkResult<List<Category>> {
|
||||
return try {
|
||||
// Проверяем кеш
|
||||
val cachedCategories = CacheManager.getCachedCategories()
|
||||
if (cachedCategories != null) {
|
||||
Log.d("SupabaseApiRepository", "Returning cached categories")
|
||||
return NetworkResult.Success(cachedCategories)
|
||||
}
|
||||
|
||||
// Загружаем из сети
|
||||
val result = supabaseRepository.getCategories()
|
||||
if (result.isSuccess) {
|
||||
val categories = result.getOrNull() ?: emptyList()
|
||||
// Сохраняем в кеш
|
||||
CacheManager.cacheCategories(categories)
|
||||
NetworkResult.Success(categories)
|
||||
} else {
|
||||
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error getting categories", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRecentRadio(): NetworkResult<List<DataStation>> {
|
||||
return try {
|
||||
// Проверяем кеш
|
||||
val cachedStations = CacheManager.getCachedStations()
|
||||
if (cachedStations != null) {
|
||||
Log.d("SupabaseApiRepository", "Returning cached stations")
|
||||
return NetworkResult.Success(cachedStations)
|
||||
}
|
||||
|
||||
// Загружаем из сети
|
||||
val result = supabaseRepository.getAllStations()
|
||||
if (result.isSuccess) {
|
||||
// Преобразуем Station из APIInterface в Station из data
|
||||
val stations = result.getOrNull()?.map { apiStation ->
|
||||
DataStation(
|
||||
id = apiStation.id.toIntOrNull() ?: 0,
|
||||
name = apiStation.name,
|
||||
url = apiStation.streamUrl,
|
||||
image = apiStation.image ?: "",
|
||||
category = apiStation.category ?: "",
|
||||
country = "",
|
||||
language = "",
|
||||
isFavorite = false,
|
||||
isPlaying = false,
|
||||
listeners = apiStation.views
|
||||
)
|
||||
} ?: emptyList()
|
||||
// Сохраняем в кеш
|
||||
CacheManager.cacheStations(stations)
|
||||
NetworkResult.Success(stations)
|
||||
} else {
|
||||
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error getting recent radio", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCategoryDetail(categoryId: Int, filter: String? = null): NetworkResult<List<DataStation>> {
|
||||
return try {
|
||||
// Проверяем кеш для этой категории
|
||||
val cachedStations = CacheManager.getCachedCategoryStations(categoryId)
|
||||
if (cachedStations != null) {
|
||||
Log.d("SupabaseApiRepository", "Returning cached stations for category $categoryId")
|
||||
return NetworkResult.Success(cachedStations)
|
||||
}
|
||||
|
||||
// Получаем станции по ID категории из сети
|
||||
val stationsResult = supabaseRepository.getStationsByCategory(categoryId)
|
||||
if (stationsResult.isSuccess) {
|
||||
val stations = stationsResult.getOrNull()?.map { apiStation ->
|
||||
DataStation(
|
||||
id = apiStation.id.toIntOrNull() ?: 0,
|
||||
name = apiStation.name,
|
||||
url = apiStation.streamUrl,
|
||||
image = apiStation.image ?: "",
|
||||
category = apiStation.category ?: "",
|
||||
country = "",
|
||||
language = "",
|
||||
isFavorite = false,
|
||||
isPlaying = false,
|
||||
listeners = apiStation.views
|
||||
)
|
||||
} ?: emptyList()
|
||||
// Сохраняем в кеш
|
||||
CacheManager.cacheCategoryStations(categoryId, stations)
|
||||
NetworkResult.Success(stations)
|
||||
} else {
|
||||
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error getting category detail", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): NetworkResult<List<DataStation>> {
|
||||
return try {
|
||||
val result = supabaseRepository.searchStations(query)
|
||||
if (result.isSuccess) {
|
||||
val stations = result.getOrNull()?.map { apiStation ->
|
||||
DataStation(
|
||||
id = apiStation.id.toIntOrNull() ?: 0,
|
||||
name = apiStation.name,
|
||||
url = apiStation.streamUrl,
|
||||
image = apiStation.image ?: "",
|
||||
category = apiStation.category ?: "",
|
||||
country = "",
|
||||
language = "",
|
||||
isFavorite = false,
|
||||
isPlaying = false,
|
||||
listeners = apiStation.views
|
||||
)
|
||||
} ?: emptyList()
|
||||
NetworkResult.Success(stations)
|
||||
} else {
|
||||
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error searching", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchRTL(query: String): NetworkResult<List<DataStation>> {
|
||||
// RTL поиск - используем тот же метод поиска
|
||||
return search(query)
|
||||
}
|
||||
|
||||
suspend fun updateStationViews(stationId: Int): NetworkResult<Unit> {
|
||||
return try {
|
||||
// Сначала получаем текущую станцию
|
||||
val stationResult = supabaseRepository.getStationById(stationId.toString())
|
||||
if (stationResult.isFailure) {
|
||||
return NetworkResult.Error(stationResult.exceptionOrNull()?.message ?: "Station not found")
|
||||
}
|
||||
|
||||
val station = stationResult.getOrNull()
|
||||
if (station == null) {
|
||||
return NetworkResult.Error("Station not found")
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик просмотров
|
||||
val newViews = station.views + 1
|
||||
val updateResult = supabaseRepository.updateStationViews(stationId.toString(), newViews)
|
||||
|
||||
if (updateResult.isSuccess) {
|
||||
NetworkResult.Success(Unit)
|
||||
} else {
|
||||
NetworkResult.Error(updateResult.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseApiRepository", "Error updating station views", e)
|
||||
NetworkResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NetworkResult<out T> {
|
||||
data class Success<out T>(val data: T) : NetworkResult<T>()
|
||||
data class Error(val message: String) : NetworkResult<Nothing>()
|
||||
}
|
||||
}
|
||||
|
||||
// Data class для совместимости с HomeFragment
|
||||
data class HomeResponse(
|
||||
val news: List<NewsItem>?,
|
||||
val favorites: List<DataStation>?
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yaros.RadioUrl.core.supabase
|
||||
|
||||
import io.github.jan.supabase.createSupabaseClient
|
||||
import io.github.jan.supabase.postgrest.Postgrest
|
||||
import io.github.jan.supabase.realtime.Realtime
|
||||
|
||||
object SupabaseClient {
|
||||
private const val SUPABASE_URL = "https://emxgttrptqqaywszojxu.supabase.co"
|
||||
private const val SUPABASE_KEY = "sb_publishable_J9s-q-GiOq2g3v5EHmdYrA_IzVzFLVe"
|
||||
|
||||
val client = createSupabaseClient(
|
||||
supabaseUrl = SUPABASE_URL,
|
||||
supabaseKey = SUPABASE_KEY
|
||||
) {
|
||||
install(Postgrest)
|
||||
install(Realtime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.yaros.RadioUrl.core.supabase
|
||||
|
||||
import android.util.Log
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Station
|
||||
import io.github.jan.supabase.postgrest.from
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class SupabaseRepository {
|
||||
|
||||
/**
|
||||
* Получить все категории из таблицы category с подсчетом радиостанций
|
||||
*/
|
||||
suspend fun getCategories(): Result<List<Category>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Category")
|
||||
.select()
|
||||
.decodeList<CategoryDto>()
|
||||
|
||||
// Получаем все радиостанции для подсчета
|
||||
val stationsResult = getAllStations()
|
||||
val stations = stationsResult.getOrNull() ?: emptyList()
|
||||
|
||||
val categories = response.map { dto ->
|
||||
// Подсчитываем количество станций для каждой категории
|
||||
val stationCount = stations.count { it.category == dto.id.toString() }
|
||||
Category(
|
||||
id = dto.id,
|
||||
name = dto.category_name,
|
||||
stationsCount = stationCount,
|
||||
image = dto.category_image
|
||||
)
|
||||
}
|
||||
Result.success(categories)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error fetching categories", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все радиостанции из таблицы radio
|
||||
*/
|
||||
suspend fun getAllStations(): Result<List<Station>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Radio")
|
||||
.select()
|
||||
.decodeList<StationDto>()
|
||||
|
||||
val stations = response.map { dto ->
|
||||
Station(
|
||||
id = dto.id.toString(),
|
||||
name = dto.radio_name,
|
||||
streamUrl = dto.radio_url,
|
||||
image = dto.radio_image,
|
||||
type = dto.type,
|
||||
category = dto.category_id?.toString(),
|
||||
views = dto.view_count
|
||||
)
|
||||
}
|
||||
Result.success(stations)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error fetching all stations", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить радиостанции по категории
|
||||
*/
|
||||
suspend fun getStationsByCategory(categoryId: Int): Result<List<Station>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Radio")
|
||||
.select {
|
||||
filter {
|
||||
eq("category_id", categoryId)
|
||||
}
|
||||
}
|
||||
.decodeList<StationDto>()
|
||||
|
||||
val stations = response.map { dto ->
|
||||
Station(
|
||||
id = dto.id.toString(),
|
||||
name = dto.radio_name,
|
||||
streamUrl = dto.radio_url,
|
||||
image = dto.radio_image,
|
||||
type = dto.type,
|
||||
category = dto.category_id?.toString(),
|
||||
views = dto.view_count
|
||||
)
|
||||
}
|
||||
Result.success(stations)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error fetching stations by category", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск радиостанций по имени
|
||||
*/
|
||||
suspend fun searchStations(query: String): Result<List<Station>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Radio")
|
||||
.select {
|
||||
filter {
|
||||
ilike("radio_name", "%$query%")
|
||||
}
|
||||
}
|
||||
.decodeList<StationDto>()
|
||||
|
||||
val stations = response.map { dto ->
|
||||
Station(
|
||||
id = dto.id.toString(),
|
||||
name = dto.radio_name,
|
||||
streamUrl = dto.radio_url,
|
||||
image = dto.radio_image,
|
||||
type = dto.type,
|
||||
category = dto.category_id?.toString(),
|
||||
views = dto.view_count
|
||||
)
|
||||
}
|
||||
Result.success(stations)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error searching stations", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить радиостанцию по ID
|
||||
*/
|
||||
suspend fun getStationById(stationId: String): Result<Station?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Radio")
|
||||
.select {
|
||||
filter {
|
||||
eq("id", stationId)
|
||||
}
|
||||
}
|
||||
.decodeSingleOrNull<StationDto>()
|
||||
|
||||
val station = response?.let { dto ->
|
||||
Station(
|
||||
id = dto.id.toString(),
|
||||
name = dto.radio_name,
|
||||
streamUrl = dto.radio_url,
|
||||
image = dto.radio_image,
|
||||
type = dto.type,
|
||||
category = dto.category_id?.toString(),
|
||||
views = dto.view_count
|
||||
)
|
||||
}
|
||||
Result.success(station)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error fetching station by id", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить количество просмотров радиостанции
|
||||
*/
|
||||
suspend fun updateStationViews(stationId: String, newViews: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SupabaseClient.client
|
||||
.from("Radio")
|
||||
.update({
|
||||
set("view_count", newViews)
|
||||
}) {
|
||||
filter {
|
||||
eq("id", stationId)
|
||||
}
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error updating station views", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить категорию по ID с подсчетом радиостанций
|
||||
*/
|
||||
suspend fun getCategoryById(categoryId: Int): Result<Category?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = SupabaseClient.client
|
||||
.from("Category")
|
||||
.select {
|
||||
filter {
|
||||
eq("id", categoryId)
|
||||
}
|
||||
}
|
||||
.decodeSingleOrNull<CategoryDto>()
|
||||
|
||||
val category = response?.let { dto ->
|
||||
// Подсчитываем количество станций для этой категории
|
||||
val stationsResult = getStationsByCategory(categoryId)
|
||||
val stationCount = stationsResult.getOrNull()?.size ?: 0
|
||||
|
||||
Category(
|
||||
id = dto.id,
|
||||
name = dto.category_name,
|
||||
stationsCount = stationCount,
|
||||
image = dto.category_image
|
||||
)
|
||||
}
|
||||
Result.success(category)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SupabaseRepository", "Error fetching category by id", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTO классы для маппинга данных из Supabase
|
||||
@Serializable
|
||||
data class CategoryDto(
|
||||
val id: Int,
|
||||
val category_name: String,
|
||||
val category_image: String? = null,
|
||||
val category_status: Int? = null,
|
||||
val featured: Int? = null,
|
||||
val last_update: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StationDto(
|
||||
val id: Int,
|
||||
val category_id: Int? = null,
|
||||
val radio_name: String,
|
||||
val radio_image: String? = null,
|
||||
val radio_url: String,
|
||||
val radio_status: Int? = null,
|
||||
val view_count: Int = 0,
|
||||
val featured: Int? = null,
|
||||
val type: String? = null,
|
||||
val last_update: String? = null
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yaros.RadioUrl.data
|
||||
|
||||
data class Song(val title: String, val artist: String, val radioStation: String)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yaros.RadioUrl.data
|
||||
|
||||
data class Station(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val category: String,
|
||||
val country: String,
|
||||
val language: String,
|
||||
val image: String,
|
||||
val isFavorite: Boolean,
|
||||
val isPlaying: Boolean,
|
||||
val listeners: Int
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.yandex.mobile.ads.appopenad.AppOpenAd
|
||||
import com.yandex.mobile.ads.appopenad.AppOpenAdEventListener
|
||||
import com.yandex.mobile.ads.appopenad.AppOpenAdLoadListener
|
||||
import com.yandex.mobile.ads.appopenad.AppOpenAdLoader
|
||||
import com.yandex.mobile.ads.banner.BannerAdSize
|
||||
import com.yandex.mobile.ads.banner.BannerAdView
|
||||
import com.yandex.mobile.ads.common.AdError
|
||||
import com.yandex.mobile.ads.common.AdRequest
|
||||
import com.yandex.mobile.ads.common.AdRequestConfiguration
|
||||
import com.yandex.mobile.ads.common.AdRequestError
|
||||
import com.yandex.mobile.ads.common.ImpressionData
|
||||
import com.yandex.mobile.ads.common.MobileAds
|
||||
import com.yandex.mobile.ads.instream.MobileInstreamAds
|
||||
import com.yaros.RadioUrl.R
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class AdManager(application: Application) {
|
||||
private var activity: AppCompatActivity? = null
|
||||
private var appOpenAd: AppOpenAd? = null
|
||||
private var adRequest: AdRequest
|
||||
private var bannerAd: BannerAdView? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val bannerRefreshInterval: Long = 10000
|
||||
|
||||
init {
|
||||
MobileAds.initialize(application) {
|
||||
MobileInstreamAds.setAdGroupPreloading(true)
|
||||
MobileAds.enableLogging(true)
|
||||
|
||||
val processLifecycleObserver = DefaultProcessLifecycleObserver(
|
||||
onProcessCaseForeground = ::showAppOpenAd
|
||||
)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(processLifecycleObserver)
|
||||
}
|
||||
|
||||
adRequest = AdRequest.Builder().build()
|
||||
}
|
||||
|
||||
private inner class AdEventListener : AppOpenAdEventListener {
|
||||
override fun onAdShown() {
|
||||
loadAppOpenAd()
|
||||
Timber.tag("AppAdOpen").d("Ad has been shown.")
|
||||
}
|
||||
|
||||
override fun onAdFailedToShow(adError: AdError) {
|
||||
clearAppOpenAd()
|
||||
loadAppOpenAd()
|
||||
Timber.tag("AppAdOpen").e("Failed to show ad: ${adError.description}")
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
clearAppOpenAd()
|
||||
Timber.tag("AppAdOpen").d("Ad dismissed.")
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
Timber.tag("AppAdOpen").d("Ad clicked.")
|
||||
}
|
||||
|
||||
override fun onAdImpression(impressionData: ImpressionData?) {
|
||||
Timber.tag("AppAdOpen").d("Ad impression: $impressionData")
|
||||
}
|
||||
}
|
||||
|
||||
fun initBannerAd(activity: AppCompatActivity) {
|
||||
val bannerContainer = activity.findViewById<FrameLayout>(R.id.bannerAdView)
|
||||
bannerAd = BannerAdView(activity).apply {
|
||||
setAdUnitId("R-M-4490409-1")
|
||||
}
|
||||
bannerContainer.addView(bannerAd)
|
||||
startBannerRefresh()
|
||||
}
|
||||
|
||||
private fun startBannerRefresh() {
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
loadBannerAd()
|
||||
handler.postDelayed(this, bannerRefreshInterval)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadBannerAd() {
|
||||
activity?.let { activity ->
|
||||
bannerAd?.apply {
|
||||
val displayMetrics = activity.resources.displayMetrics
|
||||
val density = displayMetrics.density
|
||||
|
||||
// Защита от нулевой плотности
|
||||
val safeDensity = if (density == 0f) 1f else density
|
||||
|
||||
// Рассчитываем ширину баннера
|
||||
val screenWidthPixels = displayMetrics.widthPixels
|
||||
val adWidth = (screenWidthPixels / safeDensity).roundToInt()
|
||||
|
||||
// Рассчитываем максимальную высоту с защитой от деления на ноль
|
||||
val screenHeight = (displayMetrics.heightPixels / safeDensity).toInt()
|
||||
val maxAdHeight = if (screenHeight == 0) 50 else (screenHeight / 12).coerceAtLeast(50)
|
||||
|
||||
try {
|
||||
// Пытаемся установить рассчитанный размер
|
||||
setAdSize(BannerAdSize.inlineSize(activity, adWidth, maxAdHeight))
|
||||
Timber.tag("BannerAd").d("Custom size: ${adWidth}dp x ${maxAdHeight}dp")
|
||||
} catch (e: Exception) {
|
||||
// При ошибке используем стандартный размер 350x50
|
||||
setAdSize(BannerAdSize.fixedSize(activity, 350, 50))
|
||||
Timber.tag("BannerAd").e(e, "Using default banner size")
|
||||
}
|
||||
|
||||
loadAd(adRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAppOpenAd() {
|
||||
activity?.let { activity ->
|
||||
val appOpenAdLoader = AppOpenAdLoader(activity)
|
||||
val adUnitId = "R-M-4490409-4"
|
||||
val adRequestConfig = AdRequestConfiguration.Builder(adUnitId).build()
|
||||
|
||||
appOpenAdLoader.run {
|
||||
setAdLoadListener(object : AppOpenAdLoadListener {
|
||||
override fun onAdLoaded(appOpenAd: AppOpenAd) {
|
||||
this@AdManager.appOpenAd = appOpenAd
|
||||
Timber.tag("AppOpenAd").d("App open ad loaded.")
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(error: AdRequestError) {
|
||||
Timber.tag("AppOpenAd").e("Failed to load app open ad: ${error.description}")
|
||||
}
|
||||
})
|
||||
loadAd(adRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAppOpenAd() {
|
||||
activity?.let { activity ->
|
||||
appOpenAd?.apply {
|
||||
setAdEventListener(AdEventListener())
|
||||
show(activity)
|
||||
Timber.tag("AppOpenAd").d("Attempting to show app open ad.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAppOpenAd() {
|
||||
appOpenAd?.setAdEventListener(null)
|
||||
appOpenAd = null
|
||||
Timber.tag("AppOpenAd").d("App open ad cleared.")
|
||||
}
|
||||
|
||||
fun destroyBannerAd() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
bannerAd?.destroy()
|
||||
Timber.tag("BannerAd").d("Banner ad destroyed.")
|
||||
}
|
||||
|
||||
fun setActivity(activity: AppCompatActivity) {
|
||||
this.activity = activity
|
||||
loadAppOpenAd()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import timber.log.Timber
|
||||
|
||||
object AppThemeHelper {
|
||||
|
||||
private val TAG: String = AppThemeHelper::class.java.simpleName
|
||||
|
||||
private val sTypedValue = TypedValue()
|
||||
|
||||
fun setTheme(nightModeState: String) {
|
||||
when (nightModeState) {
|
||||
Keys.STATE_THEME_DARK_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
Timber.tag(TAG).i("Dark Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_LIGHT_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_NO) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
Timber.tag(TAG).i("Theme: Light Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Timber.tag(TAG).i("Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Timber.tag(TAG).i("Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.metadata.icy.IcyHeaders
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import timber.log.Timber
|
||||
import kotlin.math.min
|
||||
|
||||
object AudioHelper {
|
||||
|
||||
private val TAG: String = AudioHelper::class.java.simpleName
|
||||
|
||||
@SuppressLint("TimberArgCount")
|
||||
@UnstableApi
|
||||
fun getMetadataString(metadata: Metadata): String {
|
||||
var metadataString = String()
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val entry = metadata.get(i)) {
|
||||
is IcyInfo -> {
|
||||
metadataString = entry.title.toString()
|
||||
}
|
||||
|
||||
is IcyHeaders -> {
|
||||
Timber.tag(TAG).i("%s%s", "%s - ", "icyHeaders:%s", entry.name, entry.genre)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.tag(TAG)
|
||||
.w("Unsupported metadata received (type = ${entry.javaClass.simpleName})")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metadataString.isNotEmpty()) {
|
||||
metadataString = metadataString.substring(0, min(metadataString.length, Keys.DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY))
|
||||
}
|
||||
return metadataString
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.R
|
||||
import timber.log.Timber
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
object BackupHelper {
|
||||
|
||||
|
||||
private val TAG: String = BackupHelper::class.java.simpleName
|
||||
|
||||
|
||||
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 {
|
||||
Timber.tag(TAG).e("Unable to access External Storage.")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
|
||||
while (zipEntry != null) {
|
||||
try {
|
||||
val newFile: File = getFile(destinationFolder!!, zipEntry)
|
||||
when (zipEntry.isDirectory) {
|
||||
true -> {
|
||||
if (!newFile.isDirectory && !newFile.mkdirs()) {
|
||||
Timber.tag(TAG).w("Failed to create directory $newFile")
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
val parent: File? = newFile.parentFile
|
||||
if (parent != null && !parent.isDirectory && !parent.mkdirs()) {
|
||||
Timber.tag(TAG).w("Failed to create directory $parent")
|
||||
}
|
||||
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) {
|
||||
Timber.tag(TAG).e("Unable to safely create file. $e")
|
||||
}
|
||||
zipEntry = zipInputStream.nextEntry
|
||||
}
|
||||
zipInputStream.closeEntry()
|
||||
zipInputStream.close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Менеджер кеширования для радиостанций и категорий
|
||||
*/
|
||||
object CacheManager {
|
||||
|
||||
private const val TAG = "CacheManager"
|
||||
|
||||
// Ключи для SharedPreferences
|
||||
private const val PREF_CACHE_ENABLED = "cache_enabled"
|
||||
private const val PREF_CACHE_MAX_SIZE = "cache_max_size_mb"
|
||||
private const val PREF_STATIONS_CACHE = "stations_cache"
|
||||
private const val PREF_STATIONS_CACHE_TIME = "stations_cache_time"
|
||||
private const val PREF_CATEGORIES_CACHE = "categories_cache"
|
||||
private const val PREF_CATEGORIES_CACHE_TIME = "categories_cache_time"
|
||||
private const val PREF_CATEGORY_STATIONS_PREFIX = "category_stations_"
|
||||
private const val PREF_CATEGORY_STATIONS_TIME_PREFIX = "category_stations_time_"
|
||||
|
||||
// Время жизни кеша (по умолчанию 1 час)
|
||||
private const val CACHE_EXPIRATION_TIME = 60 * 60 * 1000L // 1 час в миллисекундах
|
||||
|
||||
// Размеры кеша в МБ
|
||||
const val CACHE_SIZE_SMALL = 10
|
||||
const val CACHE_SIZE_MEDIUM = 25
|
||||
const val CACHE_SIZE_LARGE = 50
|
||||
const val CACHE_SIZE_XLARGE = 100
|
||||
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private val gson = Gson()
|
||||
|
||||
fun init(context: Context) {
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, включено ли кеширование
|
||||
*/
|
||||
fun isCachingEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(PREF_CACHE_ENABLED, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Включить кеширование
|
||||
*/
|
||||
fun enableCaching() {
|
||||
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключить кеширование
|
||||
*/
|
||||
fun disableCaching() {
|
||||
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, false).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить максимальный размер кеша в МБ
|
||||
*/
|
||||
fun getMaxCacheSize(): Int {
|
||||
return sharedPreferences.getInt(PREF_CACHE_MAX_SIZE, CACHE_SIZE_MEDIUM)
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить максимальный размер кеша в МБ
|
||||
*/
|
||||
fun setMaxCacheSize(sizeMb: Int) {
|
||||
sharedPreferences.edit().putInt(PREF_CACHE_MAX_SIZE, sizeMb).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить список всех радиостанций в кеш
|
||||
*/
|
||||
fun cacheStations(stations: List<Station>) {
|
||||
if (!isCachingEnabled()) return
|
||||
|
||||
try {
|
||||
val json = gson.toJson(stations)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
sharedPreferences.edit()
|
||||
.putString(PREF_STATIONS_CACHE, json)
|
||||
.putLong(PREF_STATIONS_CACHE_TIME, currentTime)
|
||||
.apply()
|
||||
|
||||
Timber.tag(TAG).d("Cached ${stations.size} stations")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error caching stations")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список всех радиостанций из кеша
|
||||
*/
|
||||
fun getCachedStations(): List<Station>? {
|
||||
if (!isCachingEnabled()) return null
|
||||
|
||||
try {
|
||||
val cacheTime = sharedPreferences.getLong(PREF_STATIONS_CACHE_TIME, 0)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// Проверяем, не истек ли срок действия кеша
|
||||
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
|
||||
Timber.tag(TAG).d("Stations cache expired")
|
||||
return null
|
||||
}
|
||||
|
||||
val json = sharedPreferences.getString(PREF_STATIONS_CACHE, null) ?: return null
|
||||
val type = object : TypeToken<List<Station>>() {}.type
|
||||
val stations: List<Station> = gson.fromJson(json, type)
|
||||
|
||||
Timber.tag(TAG).d("Retrieved ${stations.size} stations from cache")
|
||||
return stations
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error retrieving cached stations")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить список категорий в кеш
|
||||
*/
|
||||
fun cacheCategories(categories: List<Category>) {
|
||||
if (!isCachingEnabled()) return
|
||||
|
||||
try {
|
||||
val json = gson.toJson(categories)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
sharedPreferences.edit()
|
||||
.putString(PREF_CATEGORIES_CACHE, json)
|
||||
.putLong(PREF_CATEGORIES_CACHE_TIME, currentTime)
|
||||
.apply()
|
||||
|
||||
Timber.tag(TAG).d("Cached ${categories.size} categories")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error caching categories")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список категорий из кеша
|
||||
*/
|
||||
fun getCachedCategories(): List<Category>? {
|
||||
if (!isCachingEnabled()) return null
|
||||
|
||||
try {
|
||||
val cacheTime = sharedPreferences.getLong(PREF_CATEGORIES_CACHE_TIME, 0)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// Проверяем, не истек ли срок действия кеша
|
||||
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
|
||||
Timber.tag(TAG).d("Categories cache expired")
|
||||
return null
|
||||
}
|
||||
|
||||
val json = sharedPreferences.getString(PREF_CATEGORIES_CACHE, null) ?: return null
|
||||
val type = object : TypeToken<List<Category>>() {}.type
|
||||
val categories: List<Category> = gson.fromJson(json, type)
|
||||
|
||||
Timber.tag(TAG).d("Retrieved ${categories.size} categories from cache")
|
||||
return categories
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error retrieving cached categories")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить список радиостанций категории в кеш
|
||||
*/
|
||||
fun cacheCategoryStations(categoryId: Int, stations: List<Station>) {
|
||||
if (!isCachingEnabled()) return
|
||||
|
||||
try {
|
||||
val json = gson.toJson(stations)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
sharedPreferences.edit()
|
||||
.putString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", json)
|
||||
.putLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", currentTime)
|
||||
.apply()
|
||||
|
||||
Timber.tag(TAG).d("Cached ${stations.size} stations for category $categoryId")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error caching category stations")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список радиостанций категории из кеша
|
||||
*/
|
||||
fun getCachedCategoryStations(categoryId: Int): List<Station>? {
|
||||
if (!isCachingEnabled()) return null
|
||||
|
||||
try {
|
||||
val cacheTime = sharedPreferences.getLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", 0)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// Проверяем, не истек ли срок действия кеша
|
||||
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
|
||||
Timber.tag(TAG).d("Category $categoryId stations cache expired")
|
||||
return null
|
||||
}
|
||||
|
||||
val json = sharedPreferences.getString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", null) ?: return null
|
||||
val type = object : TypeToken<List<Station>>() {}.type
|
||||
val stations: List<Station> = gson.fromJson(json, type)
|
||||
|
||||
Timber.tag(TAG).d("Retrieved ${stations.size} stations for category $categoryId from cache")
|
||||
return stations
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error retrieving cached category stations")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить весь кеш
|
||||
*/
|
||||
fun clearCache() {
|
||||
try {
|
||||
val editor = sharedPreferences.edit()
|
||||
|
||||
// Удаляем кеш станций
|
||||
editor.remove(PREF_STATIONS_CACHE)
|
||||
editor.remove(PREF_STATIONS_CACHE_TIME)
|
||||
|
||||
// Удаляем кеш категорий
|
||||
editor.remove(PREF_CATEGORIES_CACHE)
|
||||
editor.remove(PREF_CATEGORIES_CACHE_TIME)
|
||||
|
||||
// Удаляем кеш станций по категориям
|
||||
val allKeys = sharedPreferences.all.keys
|
||||
allKeys.forEach { key ->
|
||||
if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX) ||
|
||||
key.startsWith(PREF_CATEGORY_STATIONS_TIME_PREFIX)) {
|
||||
editor.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
editor.apply()
|
||||
Timber.tag(TAG).d("Cache cleared")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error clearing cache")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить размер кеша в байтах (приблизительно)
|
||||
*/
|
||||
fun getCacheSize(): Long {
|
||||
try {
|
||||
var totalSize = 0L
|
||||
|
||||
// Размер кеша станций
|
||||
sharedPreferences.getString(PREF_STATIONS_CACHE, null)?.let {
|
||||
totalSize += it.length * 2 // UTF-16, 2 байта на символ
|
||||
}
|
||||
|
||||
// Размер кеша категорий
|
||||
sharedPreferences.getString(PREF_CATEGORIES_CACHE, null)?.let {
|
||||
totalSize += it.length * 2
|
||||
}
|
||||
|
||||
// Размер кеша станций по категориям
|
||||
val allKeys = sharedPreferences.all.keys
|
||||
allKeys.forEach { key ->
|
||||
if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX)) {
|
||||
sharedPreferences.getString(key, null)?.let {
|
||||
totalSize += it.length * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error calculating cache size")
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить размер кеша в МБ
|
||||
*/
|
||||
fun getCacheSizeMB(): Double {
|
||||
return getCacheSize() / (1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.ui.search.DirectInputCheck
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
object CollectionHelper {
|
||||
|
||||
private val TAG: String = CollectionHelper::class.java.simpleName
|
||||
|
||||
fun isNewStation(collection: Collection, station: Station): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.getStreamUri() == station.getStreamUri()) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun isNewStation(collection: Collection, remoteStationLocation: String): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.remoteStationLocation == remoteStationLocation) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun createStationFromPlaylistFile(
|
||||
context: Context,
|
||||
localFileUri: Uri,
|
||||
remoteFileLocation: String
|
||||
): Station {
|
||||
val station: Station =
|
||||
FileHelper.readStationPlaylist(context.contentResolver.openInputStream(localFileUri))
|
||||
if (station.name.isEmpty()) {
|
||||
station.name = FileHelper.getFileName(context, localFileUri).substringBeforeLast(".")
|
||||
}
|
||||
station.remoteStationLocation = remoteFileLocation
|
||||
station.remoteImageLocation = getFaviconAddress(remoteFileLocation)
|
||||
station.modificationDate = GregorianCalendar.getInstance().time
|
||||
return station
|
||||
}
|
||||
|
||||
fun updateStation(context: Context, collection: Collection, station: Station): Collection {
|
||||
var updatedCollection: Collection = collection
|
||||
|
||||
if (station.radioBrowserStationUuid.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.radioBrowserStationUuid == station.radioBrowserStationUuid) {
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
it.remoteStationLocation = station.remoteStationLocation
|
||||
it.homepage = station.homepage
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
} else if (station.remoteStationLocation.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.remoteStationLocation == station.remoteStationLocation) {
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
if (!it.imageManuallySet) DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
}
|
||||
|
||||
return updatedCollection
|
||||
}
|
||||
|
||||
fun addStation(context: Context, collection: Collection, newStation: Station): Collection {
|
||||
if (!newStation.isValid()) {
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return collection
|
||||
} else if (!isNewStation(collection, newStation)) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, R.string.toastmessage_station_duplicate, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return collection
|
||||
} else {
|
||||
var updatedCollection: Collection = collection
|
||||
val updatedStationList: MutableList<Station> = collection.stations.toMutableList()
|
||||
updatedStationList.add(newStation)
|
||||
updatedCollection.stations = updatedStationList
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
DownloadHelper.updateStationImage(context, newStation)
|
||||
return updatedCollection
|
||||
}
|
||||
}
|
||||
|
||||
fun setStationImageWithRemoteLocation(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: String,
|
||||
remoteFileLocation: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
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
|
||||
}
|
||||
}
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
fun setStationImageWithStationUuid(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: Uri,
|
||||
stationUuid: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
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
|
||||
}
|
||||
}
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun deleteStationImages(context: Context, station: Station) {
|
||||
val imagesFolder = File(
|
||||
context.getExternalFilesDir(""),
|
||||
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
|
||||
)
|
||||
FileHelper.clearFolder(imagesFolder, 0, true)
|
||||
}
|
||||
|
||||
fun getStation(collection: Collection, stationUuid: String): Station {
|
||||
collection.stations.forEach { station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return station
|
||||
}
|
||||
}
|
||||
return if (collection.stations.isNotEmpty()) {
|
||||
collection.stations.first()
|
||||
} else {
|
||||
Station()
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
fun getStationPosition(collection: Collection, stationUuid: String): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fun getStationPositionFromRadioBrowserStationUuid(
|
||||
collection: Collection,
|
||||
radioBrowserStationUuid: String
|
||||
): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.radioBrowserStationUuid == radioBrowserStationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fun getChildren(context: Context, collection: Collection): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
collection.stations.forEach { station ->
|
||||
mediaItems.add(buildMediaItem(context, station))
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
fun getItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
return buildMediaItem(context, getStation(collection, stationUuid))
|
||||
}
|
||||
|
||||
fun getRecent(context: Context, collection: Collection): MediaItem {
|
||||
return buildMediaItem(
|
||||
context,
|
||||
getStation(collection, PreferencesHelper.loadLastPlayedStationUuid())
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun savePlaybackState(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
stationUuid: String,
|
||||
isPlaying: Boolean
|
||||
): Collection {
|
||||
collection.stations.forEach {
|
||||
it.isPlaying = false
|
||||
if (it.uuid == stationUuid) {
|
||||
it.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
collection.modificationDate = saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
fun saveCollection(context: Context, collection: Collection, async: Boolean = true): Date {
|
||||
Timber.tag(TAG)
|
||||
.v("Saving collection of radio stations to storage. Async = ${async}. Size = ${collection.stations.size}")
|
||||
val date: Date = Calendar.getInstance().time
|
||||
collection.modificationDate = date
|
||||
when (async) {
|
||||
true -> {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.saveCollectionSuspended(context, collection, date)
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
|
||||
false -> {
|
||||
FileHelper.saveCollection(context, collection, date)
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (Keys.MIME_TYPES_M3U.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readM3uPlaylistContent(lines))
|
||||
} else if (Keys.MIME_TYPES_PLS.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readPlsPlaylistContent(lines))
|
||||
} 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)
|
||||
) {
|
||||
directInputCheck?.processIcecastStream(query, stationList)
|
||||
val station = Station(
|
||||
name = query,
|
||||
streamUris = mutableListOf(query),
|
||||
streamContent = contentType,
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
)
|
||||
if (lastCheckedAddress != query) {
|
||||
stationList.add(station)
|
||||
}
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
suspend fun createStationListFromContentUri(context: Context, contentUri: Uri): List<Station> {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val fileType: String = FileHelper.getContentType(context, contentUri)
|
||||
if (Keys.MIME_TYPES_M3U.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readM3uPlaylistContent(playlist))
|
||||
} else if (Keys.MIME_TYPES_PLS.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readPlsPlaylistContent(playlist))
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
private suspend fun readM3uPlaylistContent(playlist: List<String>): List<Station> {
|
||||
val stations: MutableList<Station> = mutableListOf()
|
||||
var name = String()
|
||||
var streamUri: String
|
||||
var contentType: String
|
||||
|
||||
playlist.forEach { line ->
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
name = line.substringAfter(",").trim()
|
||||
} else if (line.isNotBlank() && !line.startsWith("#")) {
|
||||
streamUri = line.trim()
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
contentType =
|
||||
NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
val station = Station(
|
||||
name = name,
|
||||
streamUris = mutableListOf(streamUri),
|
||||
streamContent = contentType,
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
)
|
||||
stations.add(station)
|
||||
}
|
||||
name = String()
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
private suspend 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 ->
|
||||
if (line.startsWith("File")) {
|
||||
streamUri = line.substringAfter("=").trim()
|
||||
contentType =
|
||||
NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
val number: String = line.substring(4 /* File */, line.indexOf("="))
|
||||
val lineBeforeIndex: Int = index - 1
|
||||
val lineAfterIndex: Int = index + 1
|
||||
if (lineBeforeIndex >= 0) {
|
||||
val lineBefore: String = playlist[lineBeforeIndex]
|
||||
if (lineBefore.startsWith("Title$number")) {
|
||||
name = lineBefore.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
if (name.isEmpty() && lineAfterIndex < playlist.size) {
|
||||
val lineAfter: String = playlist[lineAfterIndex]
|
||||
if (lineAfter.startsWith("Title$number")) {
|
||||
name = lineAfter.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
val station = Station(
|
||||
name = name,
|
||||
streamUris = mutableListOf(streamUri),
|
||||
streamContent = contentType,
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
)
|
||||
stations.add(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
fun exportCollectionM3u(context: Context, collection: Collection) {
|
||||
Timber.tag(TAG).v("Exporting collection of stations as M3U")
|
||||
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsM3uSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createM3uString(collection: Collection): String {
|
||||
val m3uString = StringBuilder()
|
||||
|
||||
m3uString.append("#EXTM3U")
|
||||
m3uString.append("\n")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun exportCollectionPls(context: Context, collection: Collection) {
|
||||
Timber.tag(TAG).v("Exporting collection of stations as PLS")
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsPlsSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlsString(collection: Collection): String {
|
||||
|
||||
val plsString = StringBuilder()
|
||||
var counter = 1
|
||||
|
||||
plsString.append("[playlist]")
|
||||
plsString.append("\n")
|
||||
|
||||
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++
|
||||
}
|
||||
|
||||
plsString.append("\n")
|
||||
plsString.append("NumberOfEntries=${collection.stations.size}")
|
||||
plsString.append("\n")
|
||||
plsString.append("Version=2")
|
||||
|
||||
return plsString.toString()
|
||||
}
|
||||
|
||||
fun sendCollectionBroadcast(context: Context, modificationDate: Date) {
|
||||
Timber.tag(TAG).v("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()
|
||||
// }
|
||||
|
||||
fun buildMediaItem(context: Context, station: Station): MediaItem {
|
||||
val requestMetadata = MediaItem.RequestMetadata.Builder().apply {
|
||||
setMediaUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
val mediaMetadata = MediaMetadata.Builder().apply {
|
||||
setArtist(station.name)
|
||||
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
|
||||
setArtworkData(
|
||||
station.image.toUri().toFile().readBytes(),
|
||||
MediaMetadata.PICTURE_TYPE_FRONT_COVER
|
||||
)
|
||||
} else {
|
||||
setArtworkData(
|
||||
ImageHelper.getStationImageAsByteArray(context),
|
||||
MediaMetadata.PICTURE_TYPE_FRONT_COVER
|
||||
)
|
||||
}
|
||||
setIsBrowsable(false)
|
||||
setIsPlayable(true)
|
||||
}.build()
|
||||
return MediaItem.Builder().apply {
|
||||
setMediaId(station.uuid)
|
||||
setRequestMetadata(requestMetadata)
|
||||
setMediaMetadata(mediaMetadata)
|
||||
setUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
}
|
||||
|
||||
fun sortCollection(collection: Collection): Collection {
|
||||
val favoriteStations = collection.stations.filter { it.starred }
|
||||
val otherStations = collection.stations.filter { !it.starred }
|
||||
collection.stations = (favoriteStations + otherStations).toMutableList()
|
||||
return collection
|
||||
}
|
||||
|
||||
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) {
|
||||
Timber.tag(TAG).e("Unable to get base URL from $urlString.\n$e ")
|
||||
}
|
||||
return faviconAddress
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.util.Log
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import timber.log.Timber
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object DateTimeHelper {
|
||||
|
||||
|
||||
private val TAG: String = DateTimeHelper::class.java.simpleName
|
||||
|
||||
|
||||
private const val pattern: String = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
|
||||
|
||||
fun convertFromRfc2822(dateString: String): Date {
|
||||
val date: Date = try {
|
||||
dateFormat.parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w("Unable to parse. Trying an alternative Date format. $e")
|
||||
tryAlternativeRfc2822Parsing(dateString)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
fun convertToRfc2822(date: Date): String {
|
||||
val dateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
return dateFormat.format(date)
|
||||
}
|
||||
|
||||
|
||||
fun convertToHoursMinutesSeconds(milliseconds: Long, negativeValue: Boolean = false): String {
|
||||
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) {
|
||||
timeString = "-$timeString"
|
||||
}
|
||||
return timeString
|
||||
}
|
||||
|
||||
|
||||
private fun tryAlternativeRfc2822Parsing(dateString: String): Date {
|
||||
var date: Date = Keys.DEFAULT_DATE
|
||||
try {
|
||||
date = SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.ENGLISH).parse((dateString))
|
||||
?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
Timber.tag(TAG).w("Unable to parse. Trying an alternative Date format. $e")
|
||||
date = SimpleDateFormat(
|
||||
"EEE, dd MMM yyyy HH:mm:ss",
|
||||
Locale.ENGLISH
|
||||
).parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Unable to parse. Returning a default date. $e")
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
class DefaultProcessLifecycleObserver (
|
||||
private val onProcessCaseForeground: () -> Unit
|
||||
): DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
onProcessCaseForeground()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class DownloadFinishedReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
DownloadHelper.processDownload(
|
||||
context,
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.core.extensions.copy
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
|
||||
object DownloadHelper {
|
||||
|
||||
|
||||
private val TAG: String = DownloadHelper::class.java.simpleName
|
||||
|
||||
|
||||
private lateinit var collection: Collection
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var activeDownloads: ArrayList<Long>
|
||||
private lateinit var modificationDate: Date
|
||||
|
||||
|
||||
fun downloadPlaylists(context: Context, playlistUrlStrings: Array<String>) {
|
||||
initialize(context)
|
||||
val uris: Array<Uri> =
|
||||
Array(playlistUrlStrings.size) { index -> playlistUrlStrings[index].toUri() }
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_PLAYLIST)
|
||||
}
|
||||
|
||||
|
||||
fun updateStationImage(context: Context, station: Station) {
|
||||
initialize(context)
|
||||
if (station.remoteImageLocation.isNotEmpty()) {
|
||||
CollectionHelper.clearImagesFolder(context, station)
|
||||
val uris: Array<Uri> = Array(1) { station.remoteImageLocation.toUri() }
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_IMAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateStationImages(context: Context) {
|
||||
initialize(context)
|
||||
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)
|
||||
Timber.tag(TAG).i("Updating all station images.")
|
||||
}
|
||||
|
||||
|
||||
fun processDownload(context: Context, downloadId: Long) {
|
||||
initialize(context)
|
||||
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()
|
||||
Timber.tag(TAG)
|
||||
.w("Download not successful: File name = $downloadErrorFileName Error code = $downloadErrorCode")
|
||||
removeFromActiveDownloads(arrayOf(downloadId), deleteDownload = true)
|
||||
return
|
||||
} else {
|
||||
val localFileUri: Uri = downloadResult
|
||||
val remoteFileLocation: String = getRemoteFileLocation(downloadManager, downloadId)
|
||||
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
|
||||
)
|
||||
}
|
||||
removeFromActiveDownloads(arrayOf(downloadId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun enqueueDownload(
|
||||
context: Context,
|
||||
uris: Array<Uri>,
|
||||
type: Int,
|
||||
ignoreWifiRestriction: Boolean = false
|
||||
) {
|
||||
val allowedNetworkTypes: Int = determineAllowedNetworkTypes(type, ignoreWifiRestriction)
|
||||
val newIds = LongArray(uris.size)
|
||||
for (i in uris.indices) {
|
||||
Timber.tag(TAG).v("DownloadManager enqueue: ${uris[i]}")
|
||||
val uri: Uri = uris[i]
|
||||
val scheme: String = uri.scheme ?: ""
|
||||
val pathSegments: List<String> = uri.pathSegments
|
||||
if (scheme.isEmpty() || !scheme.startsWith("http")) {
|
||||
Timber.tag(TAG).e("Unsupported URI scheme: $scheme for URI: $uri")
|
||||
val errorMessage = context.getString(R.string.dialog_download_unsupport, scheme, uri)
|
||||
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
continue // Пропускаем этот URI, так как он неподдерживаемый
|
||||
}
|
||||
if (isNotInDownloadQueue(uri.toString()) && pathSegments.isNotEmpty()) {
|
||||
val fileName: String = pathSegments.last()
|
||||
try {
|
||||
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])
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.tag(TAG).e(e, "Error enqueuing download for URI: $uri")
|
||||
val errorMessage = context.getString(R.string.dialog_download_error, uri)
|
||||
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Timber.tag(TAG)
|
||||
.e("URI is already in download queue or path segments are empty for URI: $uri")
|
||||
val reapeatMessage = context.getString(R.string.dialog_download_repeat, uri)
|
||||
Toast.makeText(context, reapeatMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun isNotInDownloadQueue(remoteFileLocation: String): Boolean {
|
||||
val activeDownloadsCopy = activeDownloads.copy()
|
||||
activeDownloadsCopy.forEach { downloadId ->
|
||||
if (getRemoteFileLocation(downloadManager, downloadId) == remoteFileLocation) {
|
||||
Timber.tag(TAG).w("File is already in download queue: $remoteFileLocation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
Timber.tag(TAG).v("File is not in download queue.")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
private fun removeFromActiveDownloads(
|
||||
downloadIds: Array<Long>,
|
||||
deleteDownload: Boolean = false
|
||||
): Boolean {
|
||||
val success: Boolean =
|
||||
activeDownloads.removeAll { downloadId -> downloadIds.contains(downloadId) }
|
||||
if (success) {
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
if (deleteDownload) {
|
||||
downloadIds.forEach { downloadId -> downloadManager.remove(downloadId) }
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
|
||||
private fun addStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
station.streamContent = contentType.type
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.addStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
station.streamContent = contentType.type
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
private fun determineAllowedNetworkTypes(type: Int, ignoreWifiRestriction: Boolean): Int {
|
||||
var allowedNetworkTypes: Int =
|
||||
(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
|
||||
if (type == Keys.FILE_TYPE_AUDIO) {
|
||||
if (!ignoreWifiRestriction && !PreferencesHelper.downloadOverMobile()) {
|
||||
allowedNetworkTypes = DownloadManager.Request.NETWORK_WIFI
|
||||
}
|
||||
}
|
||||
return allowedNetworkTypes
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.http.NetworkException
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.yaros.RadioUrl.MainActivity
|
||||
import com.yaros.RadioUrl.R
|
||||
import timber.log.Timber
|
||||
|
||||
class FMessagingService : FirebaseMessagingService() {
|
||||
|
||||
private fun isFirebaseAvailable(): Boolean {
|
||||
return try {
|
||||
FirebaseApp.getInstance() != null
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.Forest.tag(TAG).e(e, "Firebase not initialized")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Unexpected Firebase error")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
try {
|
||||
if (!isFirebaseAvailable()) {
|
||||
Timber.Forest.tag(TAG).w("Received message but Firebase is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
remoteMessage.notification?.let { notification ->
|
||||
try {
|
||||
handleNotification(notification)
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Error processing notification")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "General message handling error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNotification(notification: RemoteMessage.Notification) {
|
||||
val title = notification.title
|
||||
val body = notification.body
|
||||
|
||||
try {
|
||||
val resultIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
|
||||
val resultPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
resultIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val channelId = "2038"
|
||||
createNotificationChannel(channelId)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(resultPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setLights(Color.BLUE, 1000, 300)
|
||||
|
||||
showNotification(notificationBuilder)
|
||||
Timber.Forest.tag(TAG).d("Message processed successfully: $body")
|
||||
} catch (e: SecurityException) {
|
||||
Timber.Forest.tag(TAG).e(e, "PendingIntent security exception")
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Notification creation failed")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun createNotificationChannel(channelId: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
val channelName = "SoundWave"
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = getString(R.string.descr_notification)
|
||||
enableLights(true)
|
||||
lightColor = Color.BLUE
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Channel creation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(builder: NotificationCompat.Builder) {
|
||||
try {
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(0, builder.build())
|
||||
} catch (e: NullPointerException) {
|
||||
Timber.Forest.tag(TAG).e(e, "Notification manager not available")
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Notification showing failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
try {
|
||||
sendRegistrationToServer(token)
|
||||
Timber.Forest.tag(TAG).d("New token processed: $token")
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Token processing failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRegistrationToServer(token: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
try {
|
||||
// Реальная логика отправки токена
|
||||
Timber.Forest.tag(TAG).d("Token sent to server successfully")
|
||||
} catch (e: NetworkException) {
|
||||
Timber.Forest.tag(TAG).e(e, "Network error during token send")
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.tag(TAG).e(e, "Token sending failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MyFMsgService"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.yaros.RadioUrl.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 androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import timber.log.Timber
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
object FileHelper {
|
||||
|
||||
|
||||
private val TAG: String = FileHelper::class.java.simpleName
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getContentType(context: Context, uri: Uri): String {
|
||||
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)) {
|
||||
contentType
|
||||
} else {
|
||||
getContentTypeFromExtension(getFileName(context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getContentTypeFromExtension(fileName: String): String {
|
||||
Timber.tag(TAG).i("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
|
||||
return Keys.MIME_TYPE_UNSUPPORTED
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
fun saveCollection(context: Context, collection: Collection, lastSave: Date) {
|
||||
Timber.tag(TAG).v("Saving collection - Thread: ${Thread.currentThread().name}")
|
||||
val collectionSize: Int = collection.stations.size
|
||||
if (collectionSize > 0 || PreferencesHelper.loadCollectionSize() == 1) {
|
||||
val gson: Gson = getCustomGson()
|
||||
var json = String()
|
||||
try {
|
||||
json = gson.toJson(collection)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (json.isNotBlank()) {
|
||||
writeTextFile(context, json, Keys.FOLDER_COLLECTION, Keys.COLLECTION_FILE)
|
||||
PreferencesHelper.saveCollectionModificationDate(lastSave)
|
||||
PreferencesHelper.saveCollectionSize(collectionSize)
|
||||
} else {
|
||||
Timber.tag(TAG)
|
||||
.w("Not writing collection file. Reason: JSON string was completely empty.")
|
||||
}
|
||||
} else {
|
||||
Timber.tag(TAG)
|
||||
.w("Not saving collection. Reason: Trying to override an collection with more than one station")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun readStationPlaylist(playlistInputStream: InputStream?): Station {
|
||||
val station = Station()
|
||||
if (playlistInputStream != null) {
|
||||
val reader = BufferedReader(InputStreamReader(playlistInputStream))
|
||||
reader.forEachLine { line ->
|
||||
when {
|
||||
line.contains("#EXTINF:-1,") -> station.name = line.substring(11).trim()
|
||||
line.contains("#EXTINF:0,") -> station.name = line.substring(10).trim()
|
||||
line.startsWith("http") -> station.streamUris.add(0, line.trim())
|
||||
line.matches(Regex("^Title[0-9]+=.*")) -> station.name =
|
||||
line.substring(line.indexOf("=") + 1).trim()
|
||||
line.matches(Regex("^File[0-9]+=http.*")) -> station.streamUris.add(
|
||||
line.substring(
|
||||
line.indexOf("=") + 1
|
||||
).trim()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
playlistInputStream.close()
|
||||
}
|
||||
return station
|
||||
}
|
||||
|
||||
|
||||
fun readCollection(context: Context): Collection {
|
||||
Timber.tag(TAG).v("Reading collection - Thread: ${Thread.currentThread().name}")
|
||||
val json: String = readTextFileFromFile(context)
|
||||
var collection = Collection()
|
||||
if (json.isNotBlank()) {
|
||||
try {
|
||||
collection = getCustomGson().fromJson(json, collection::class.java)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error Reading collection.\nContent: $json")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
fun getM3ulUri(activity: Activity): Uri? {
|
||||
var m3ulUri: Uri? = null
|
||||
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
|
||||
)
|
||||
}
|
||||
if (m3uFile.exists()) {
|
||||
m3ulUri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.applicationContext.packageName}.provider",
|
||||
m3uFile
|
||||
)
|
||||
}
|
||||
return m3ulUri
|
||||
}
|
||||
|
||||
|
||||
fun getPlslUri(activity: Activity): Uri? {
|
||||
var plslUri: Uri? = null
|
||||
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
|
||||
)
|
||||
}
|
||||
if (plsFile.exists()) {
|
||||
plslUri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.applicationContext.packageName}.provider",
|
||||
plsFile
|
||||
)
|
||||
}
|
||||
return plslUri
|
||||
}
|
||||
|
||||
|
||||
suspend fun saveCollectionSuspended(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
lastUpdate: Date
|
||||
) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(saveCollection(context, collection, lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun readCollectionSuspended(context: Context): Collection =
|
||||
withContext(IO) {
|
||||
readCollection(context)
|
||||
}
|
||||
|
||||
|
||||
suspend fun saveCopyOfFileSuspended(
|
||||
context: Context,
|
||||
originalFileUri: Uri,
|
||||
targetFileUri: Uri
|
||||
): Boolean {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(copyFile(context, originalFileUri, targetFileUri))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun backupCollectionAsM3uSuspended(context: Context, collection: Collection) {
|
||||
return suspendCoroutine { cont ->
|
||||
Timber.tag(TAG)
|
||||
.v("Backing up collection as M3U - Thread: ${Thread.currentThread().name}")
|
||||
val m3uString: String = CollectionHelper.createM3uString(collection)
|
||||
cont.resume(
|
||||
writeTextFile(
|
||||
context,
|
||||
m3uString,
|
||||
Keys.FOLDER_COLLECTION,
|
||||
Keys.COLLECTION_M3U_FILE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun backupCollectionAsPlsSuspended(context: Context, collection: Collection) {
|
||||
return suspendCoroutine { cont ->
|
||||
Timber.tag(TAG)
|
||||
.v("Backing up collection as PLS - Thread: ${Thread.currentThread().name}")
|
||||
val plsString: String = CollectionHelper.createPlsString(collection)
|
||||
cont.resume(
|
||||
writeTextFile(
|
||||
context,
|
||||
plsString,
|
||||
Keys.FOLDER_COLLECTION,
|
||||
Keys.COLLECTION_PLS_FILE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Timber.tag(TAG).e("Unable to copy file.")
|
||||
success = false
|
||||
exception.printStackTrace()
|
||||
} finally {
|
||||
inputStream?.close()
|
||||
}
|
||||
if (success) {
|
||||
try {
|
||||
context.contentResolver.delete(originalFileUri, null, null)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Unable to delete the original file. Stack trace: $e")
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
|
||||
private fun getCustomGson(): Gson {
|
||||
val gsonBuilder = GsonBuilder()
|
||||
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
|
||||
gsonBuilder.excludeFieldsWithoutExposeAnnotation()
|
||||
return gsonBuilder.create()
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
Timber.tag(TAG).v(".nomedia file exists already in given folder.")
|
||||
}
|
||||
} else {
|
||||
Timber.tag(TAG).w("Unable to create .nomedia file. Given folder is not valid.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun readTextFileFromFile(context: Context): String {
|
||||
|
||||
val file = File(context.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_FILE)
|
||||
if (!file.exists() || !file.canRead()) {
|
||||
return String()
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
fun readTextFileFromContentUri(context: Context, contentUri: Uri): List<String> {
|
||||
val lines: MutableList<String> = mutableListOf()
|
||||
try {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun writeTextFile(context: Context, text: String, folder: String, fileName: String) {
|
||||
if (text.isNotBlank()) {
|
||||
File(context.getExternalFilesDir(folder), fileName).writeText(text)
|
||||
} else {
|
||||
Timber.tag(TAG)
|
||||
.w("Writing text file $fileName failed. Empty text string text was provided.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getNoMediaFile(folder: File): File {
|
||||
return File(folder, ".nomedia")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Централизованный помощник для runtime-детекции Google Services
|
||||
* Обеспечивает универсальную работу приложения на устройствах с/без Google Services
|
||||
*/
|
||||
object GoogleServicesHelper {
|
||||
|
||||
private const val TAG = "GoogleServicesHelper"
|
||||
|
||||
@Volatile
|
||||
private var googleServicesAvailable: Boolean? = null
|
||||
|
||||
@Volatile
|
||||
private var firebaseAvailable: Boolean? = null
|
||||
|
||||
/**
|
||||
* Проверяет доступность Google Play Services на устройстве
|
||||
*/
|
||||
fun isGooglePlayServicesAvailable(context: Context): Boolean {
|
||||
if (googleServicesAvailable != null) {
|
||||
return googleServicesAvailable!!
|
||||
}
|
||||
|
||||
return try {
|
||||
val googleApiAvailability = GoogleApiAvailability.getInstance()
|
||||
val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context)
|
||||
val available = resultCode == ConnectionResult.SUCCESS
|
||||
|
||||
googleServicesAvailable = available
|
||||
|
||||
if (!available) {
|
||||
Timber.tag(TAG).w("Google Play Services unavailable. Code: $resultCode")
|
||||
} else {
|
||||
Timber.tag(TAG).d("Google Play Services available")
|
||||
}
|
||||
|
||||
available
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error checking Google Play Services availability")
|
||||
googleServicesAvailable = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет доступность Firebase
|
||||
*/
|
||||
fun isFirebaseAvailable(): Boolean {
|
||||
if (firebaseAvailable != null) {
|
||||
return firebaseAvailable!!
|
||||
}
|
||||
|
||||
return try {
|
||||
Class.forName("com.google.firebase.FirebaseApp")
|
||||
firebaseAvailable = true
|
||||
Timber.tag(TAG).d("Firebase available")
|
||||
true
|
||||
} catch (e: ClassNotFoundException) {
|
||||
Timber.tag(TAG).w("Firebase not available: ${e.message}")
|
||||
firebaseAvailable = false
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error checking Firebase availability")
|
||||
firebaseAvailable = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасная инициализация Firebase
|
||||
*/
|
||||
fun initFirebaseSafely(context: Context): Boolean {
|
||||
if (!isFirebaseAvailable()) {
|
||||
Timber.tag(TAG).w("Skipping Firebase initialization - not available")
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val firebaseApp = com.google.firebase.FirebaseApp.initializeApp(context)
|
||||
if (firebaseApp != null) {
|
||||
Timber.tag(TAG).d("Firebase initialized successfully")
|
||||
true
|
||||
} else {
|
||||
Timber.tag(TAG).w("Firebase already initialized")
|
||||
true
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
// Firebase уже инициализирован
|
||||
Timber.tag(TAG).d("Firebase already initialized")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Firebase initialization failed")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить Firebase Analytics (если доступен)
|
||||
*/
|
||||
fun getFirebaseAnalytics(context: Context): Any? {
|
||||
if (!isFirebaseAvailable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
com.google.firebase.analytics.FirebaseAnalytics.getInstance(context)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to get Firebase Analytics")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное логирование события в Firebase Analytics
|
||||
*/
|
||||
fun logAnalyticsEvent(context: Context, eventName: String, params: Map<String, Any?> = emptyMap()) {
|
||||
if (!isFirebaseAvailable()) {
|
||||
Timber.tag(TAG).d("Analytics event (no Firebase): $eventName")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val analytics = getFirebaseAnalytics(context) as? com.google.firebase.analytics.FirebaseAnalytics
|
||||
if (analytics != null) {
|
||||
val bundle = Bundle().apply {
|
||||
params.forEach { (key, value) ->
|
||||
when (value) {
|
||||
is String -> putString(key, value)
|
||||
is Int -> putInt(key, value)
|
||||
is Long -> putLong(key, value)
|
||||
is Double -> putDouble(key, value)
|
||||
is Boolean -> putBoolean(key, value)
|
||||
null -> putString(key, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
analytics.logEvent(eventName, bundle)
|
||||
Timber.tag(TAG).d("Analytics event logged: $eventName")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to log analytics event: $eventName")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить Firebase Messaging токен (если доступен)
|
||||
*/
|
||||
fun getFirebaseMessagingToken(onSuccess: (String) -> Unit, onFailure: (Exception) -> Unit) {
|
||||
if (!isFirebaseAvailable()) {
|
||||
Timber.tag(TAG).w("Firebase Messaging not available")
|
||||
onFailure(Exception("Firebase not available"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
com.google.firebase.messaging.FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful && task.result != null) {
|
||||
onSuccess(task.result!!)
|
||||
} else {
|
||||
onFailure(task.exception ?: Exception("Unknown error"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to get FCM token")
|
||||
onFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли показывать функционал, зависящий от Google Services
|
||||
*/
|
||||
fun shouldEnableGoogleFeatures(context: Context): Boolean {
|
||||
return isGooglePlayServicesAvailable(context) && isFirebaseAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить информацию о доступности сервисов для отладки
|
||||
*/
|
||||
fun getServicesStatus(context: Context): String {
|
||||
return buildString {
|
||||
appendLine("Google Services Status:")
|
||||
appendLine("- Google Play Services: ${if (isGooglePlayServicesAvailable(context)) "✓ Available" else "✗ Not Available"}")
|
||||
appendLine("- Firebase: ${if (isFirebaseAvailable()) "✓ Available" else "✗ Not Available"}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс кэша для повторной проверки (для тестирования)
|
||||
*/
|
||||
fun resetCache() {
|
||||
googleServicesAvailable = null
|
||||
firebaseAvailable = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.R
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
|
||||
object ImageHelper {
|
||||
|
||||
fun getDensityScalingFactor(context: Context): Float {
|
||||
return context.resources.displayMetrics.density
|
||||
}
|
||||
|
||||
|
||||
fun getScaledStationImage(context: Context, imageUri: Uri, imageSize: Int): Bitmap {
|
||||
val size: Int = (imageSize * getDensityScalingFactor(context)).toInt()
|
||||
return decodeSampledBitmapFromUri(context, imageUri, size, size)
|
||||
}
|
||||
|
||||
|
||||
fun getStationImage(context: Context, imageUriString: String): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
|
||||
if (imageUriString.isNotEmpty()) {
|
||||
try {
|
||||
val uri = imageUriString.toUri()
|
||||
if (uri.scheme?.startsWith("http") == true) {
|
||||
// Handle HTTP/HTTPS URL (download image)
|
||||
bitmap = downloadBitmap(uri.toString())
|
||||
} else {
|
||||
// Handle local URI
|
||||
bitmap = BitmapFactory.decodeFile(uri.path)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default image if loading failed
|
||||
return bitmap ?: ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!.toBitmap()
|
||||
}
|
||||
|
||||
private fun downloadBitmap(url: String): Bitmap? {
|
||||
return try {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.doInput = true
|
||||
connection.connect()
|
||||
val input = connection.inputStream
|
||||
BitmapFactory.decodeStream(input)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
fun createSquareImage(
|
||||
context: Context,
|
||||
bitmap: Bitmap,
|
||||
backgroundColor: Int,
|
||||
size: Int,
|
||||
adaptivePadding: Boolean
|
||||
): Bitmap {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
val outputImage: Bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val imageCanvas = Canvas(outputImage)
|
||||
|
||||
val right = size.toFloat()
|
||||
val bottom = size.toFloat()
|
||||
imageCanvas.drawRect(0f, 0f, right, bottom, background)
|
||||
|
||||
val paint = Paint()
|
||||
paint.isFilterBitmap = true
|
||||
imageCanvas.drawBitmap(
|
||||
bitmap,
|
||||
createTransformationMatrix(
|
||||
size,
|
||||
bitmap.height.toFloat(),
|
||||
bitmap.width.toFloat(),
|
||||
adaptivePadding
|
||||
),
|
||||
paint
|
||||
)
|
||||
return outputImage
|
||||
}
|
||||
|
||||
|
||||
fun getMainColor(context: Context, imageUri: Uri): Int {
|
||||
val palette: Palette =
|
||||
Palette.from(decodeSampledBitmapFromUri(context, imageUri, 72, 72)).generate()
|
||||
val vibrantSwatch = palette.vibrantSwatch
|
||||
val mutedSwatch = palette.mutedSwatch
|
||||
|
||||
when {
|
||||
vibrantSwatch != null -> {
|
||||
val rgb = vibrantSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
mutedSwatch != null -> {
|
||||
val rgb = mutedSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
else -> {
|
||||
return context.resources.getColor(R.color.default_neutral_medium_light, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun decodeSampledBitmapFromUri(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
if (imageUri.toString().isNotEmpty()) {
|
||||
try {
|
||||
var stream: InputStream? = context.contentResolver.openInputStream(imageUri)
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
|
||||
options.inSampleSize = calculateSampleParameter(options, reqWidth, reqHeight)
|
||||
|
||||
stream = context.contentResolver.openInputStream(imageUri)
|
||||
options.inJustDecodeBounds = false
|
||||
bitmap = BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap == null) {
|
||||
bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!
|
||||
.toBitmap()
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
|
||||
private fun calculateSampleParameter(
|
||||
options: BitmapFactory.Options,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Int {
|
||||
val height = options.outHeight
|
||||
val width = options.outWidth
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
|
||||
val halfHeight = height / 2
|
||||
val halfWidth = width / 2
|
||||
|
||||
while (halfHeight / inSampleSize > reqHeight && halfWidth / inSampleSize > reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
return inSampleSize
|
||||
}
|
||||
|
||||
|
||||
private fun createTransformationMatrix(
|
||||
size: Int,
|
||||
inputImageHeight: Float,
|
||||
inputImageWidth: Float,
|
||||
scaled: Boolean
|
||||
): Matrix {
|
||||
val matrix = Matrix()
|
||||
|
||||
var padding = 0f
|
||||
if (scaled) {
|
||||
padding = size.toFloat() / 4f
|
||||
}
|
||||
|
||||
val aspectRatio: Float
|
||||
val xTranslation: Float
|
||||
val yTranslation: Float
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
matrix.postTranslate(xTranslation, yTranslation)
|
||||
matrix.preScale(aspectRatio, aspectRatio)
|
||||
return matrix
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import okhttp3.*
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.InetAddress
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
object NetworkHelper {
|
||||
|
||||
private val TAG: String = NetworkHelper::class.java.simpleName
|
||||
private lateinit var appContext: Context
|
||||
private var networkQualityCallback: ((NetworkQuality) -> Unit)? = null
|
||||
|
||||
enum class NetworkQuality { POOR, MODERATE, GOOD, UNKNOWN }
|
||||
|
||||
val client: OkHttpClient by lazy {
|
||||
createSecureOkHttpClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OkHttpClient that accepts all SSL certificates
|
||||
* This is necessary for radio streams that may not have valid SSL certificates
|
||||
*/
|
||||
fun createSecureOkHttpClient(): OkHttpClient {
|
||||
val trustAllCerts = arrayOf<TrustManager>(@SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||
})
|
||||
|
||||
return try {
|
||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||
init(null, trustAllCerts, SecureRandom())
|
||||
}
|
||||
|
||||
OkHttpClient.Builder()
|
||||
.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.callTimeout(30, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor { chain ->
|
||||
try {
|
||||
val request = chain.request().newBuilder()
|
||||
.addHeader("Connection", "keep-alive")
|
||||
.addHeader("User-Agent", "SoundWaveRadio/1.0")
|
||||
.addHeader("Accept", "*/*")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Interceptor error: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error creating OkHttpClient: ${e.message}")
|
||||
// Fallback to basic client
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
fun isConnectedToNetwork(): Boolean {
|
||||
return try {
|
||||
val connMgr = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val networkCapabilities = connMgr.getNetworkCapabilities(connMgr.activeNetwork)
|
||||
networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error checking network connection: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
data class ContentType(var type: String = "", var charset: String = "")
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
setupNetworkMonitoring()
|
||||
}
|
||||
|
||||
suspend fun detectContentType(urlString: String): ContentType {
|
||||
if (!isConnectedToNetwork()) {
|
||||
throw IOException("No internet connection")
|
||||
}
|
||||
|
||||
return suspendCoroutine { cont ->
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
.head() // Use HEAD request for efficiency
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Timber.tag(TAG).e("detectContentType failed for $urlString: ${e.message}")
|
||||
when (e) {
|
||||
is SocketTimeoutException -> {
|
||||
Timber.tag(TAG).e("Socket timeout occurred: ${e.message}")
|
||||
if (e.message?.contains("SSL handshake timed out") == true) {
|
||||
Timber.tag(TAG).e("SSL handshake timed out. Possible network or server issue.")
|
||||
} else if (e.message?.contains("Read timed out") == true) {
|
||||
Timber.tag(TAG).e("Read timed out. The server might be slow or unresponsive.")
|
||||
}
|
||||
cont.resumeWithException(IOException("Request timed out. Please try again later.", e))
|
||||
}
|
||||
is ConnectException -> {
|
||||
Timber.tag(TAG).e("Connection error: ${e.message}")
|
||||
cont.resumeWithException(IOException("Unable to connect to the server.", e))
|
||||
}
|
||||
is UnknownHostException -> {
|
||||
Timber.tag(TAG).e("Unknown host: ${e.message}")
|
||||
cont.resumeWithException(IOException("Unable to resolve the server address.", e))
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(TAG).e("General IO error: ${e.message}")
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
response.use {
|
||||
val contentType = ContentType(Keys.MIME_TYPE_UNSUPPORTED, Keys.CHARSET_UNDEFINDED)
|
||||
val contentTypeHeader: String = response.header("Content-Type") ?: ""
|
||||
Timber.tag(TAG).v("Raw content type header: $contentTypeHeader for URL: $urlString")
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType.type.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
||||
Timber.tag(TAG).w("Special case \"application/octet-stream\"")
|
||||
val headerFieldContentDisposition: String? = response.header("Content-Disposition")
|
||||
if (headerFieldContentDisposition != null && headerFieldContentDisposition.contains("=")) {
|
||||
val fileName: String = headerFieldContentDisposition.split("=")[1].replace("\"", "")
|
||||
Timber.tag(TAG).i("File name from Content-Disposition: $fileName")
|
||||
} else {
|
||||
Timber.tag(TAG).i("Unable to get file name from \"Content-Disposition\" header field.")
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(TAG).i("Content type: ${contentType.type} | Character set: ${contentType.charset}")
|
||||
cont.resume(contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error processing response: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error processing server response", e))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error creating request: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error creating request", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadPlaylist(playlistUrlString: String): List<String> {
|
||||
if (!isConnectedToNetwork()) {
|
||||
throw IOException("No internet connection")
|
||||
}
|
||||
return suspendCoroutine { cont ->
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(playlistUrlString)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Timber.tag(TAG).e("downloadPlaylist failed for $playlistUrlString: ${e.message}")
|
||||
when (e) {
|
||||
is SocketTimeoutException -> {
|
||||
Timber.tag(TAG).e("Socket timeout occurred: ${e.message}")
|
||||
if (e.message?.contains("SSL handshake timed out") == true) {
|
||||
Timber.tag(TAG).e("SSL handshake timed out. Possible network or server issue.")
|
||||
} else if (e.message?.contains("Read timed out") == true) {
|
||||
Timber.tag(TAG).e("Read timed out. The server might be slow or unresponsive.")
|
||||
}
|
||||
cont.resumeWithException(IOException("Request timed out. Please try again later.", e))
|
||||
}
|
||||
is ConnectException -> {
|
||||
Timber.tag(TAG).e("Connection error: ${e.message}")
|
||||
cont.resumeWithException(IOException("Unable to connect to the server.", e))
|
||||
}
|
||||
is UnknownHostException -> {
|
||||
Timber.tag(TAG).e("Unknown host: ${e.message}")
|
||||
cont.resumeWithException(IOException("Unable to resolve the server address.", e))
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(TAG).e("General IO error: ${e.message}")
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
response.use {
|
||||
if (!response.isSuccessful) {
|
||||
Timber.tag(TAG).e("Unsuccessful response: ${response.code}")
|
||||
cont.resumeWithException(IOException("Server returned error: ${response.code}"))
|
||||
return
|
||||
}
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
response.body?.byteStream()?.bufferedReader()?.useLines { sequence ->
|
||||
sequence.take(100).forEach { line ->
|
||||
val trimmedLine = line.take(2000)
|
||||
lines.add(trimmedLine)
|
||||
}
|
||||
}
|
||||
Timber.tag(TAG).i("Downloaded playlist with ${lines.size} lines")
|
||||
cont.resume(lines)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error processing playlist response: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error processing playlist", e))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error creating playlist request: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error creating request", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun detectContentTypeSuspended(urlString: String): ContentType {
|
||||
if (!isConnectedToNetwork()) {
|
||||
throw IOException("No internet connection")
|
||||
}
|
||||
return suspendCoroutine { cont ->
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
.head() // Use HEAD request for efficiency
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Timber.tag(TAG).e("detectContentTypeSuspended failed for $urlString: ${e.message}")
|
||||
when (e) {
|
||||
is SocketTimeoutException -> Timber.tag(TAG).e("Socket timeout: ${e.message}")
|
||||
is ConnectException -> Timber.tag(TAG).e("Connection error: ${e.message}")
|
||||
is UnknownHostException -> Timber.tag(TAG).e("Unknown host: ${e.message}")
|
||||
else -> Timber.tag(TAG).e("General IO error: ${e.message}")
|
||||
}
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
response.use {
|
||||
val contentType = ContentType(Keys.MIME_TYPE_UNSUPPORTED, Keys.CHARSET_UNDEFINDED)
|
||||
val contentTypeHeader: String = response.header("Content-Type") ?: ""
|
||||
Timber.tag(TAG).v("Raw content type header: $contentTypeHeader for URL: $urlString")
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType.type.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
||||
Timber.tag(TAG).w("Special case \"application/octet-stream\"")
|
||||
val headerFieldContentDisposition: String? = response.header("Content-Disposition")
|
||||
if (headerFieldContentDisposition != null && headerFieldContentDisposition.contains("=")) {
|
||||
val fileName: String = headerFieldContentDisposition.split("=")[1].replace("\"", "")
|
||||
Timber.tag(TAG).i("File name from Content-Disposition: $fileName")
|
||||
} else {
|
||||
Timber.tag(TAG).i("Unable to get file name from \"Content-Disposition\" header field.")
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(TAG).i("Content type: ${contentType.type} | Character set: ${contentType.charset}")
|
||||
cont.resume(contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error processing response: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error processing server response", e))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error creating request: ${e.message}")
|
||||
cont.resumeWithException(IOException("Error creating request", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNetworkMonitoring() {
|
||||
val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as ConnectivityManager
|
||||
|
||||
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
super.onCapabilitiesChanged(network, capabilities)
|
||||
determineNetworkQuality(capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineNetworkQuality(capabilities: NetworkCapabilities) {
|
||||
val downstreamSpeedKbps = capabilities.linkDownstreamBandwidthKbps
|
||||
val upstreamSpeedKbps = capabilities.linkUpstreamBandwidthKbps
|
||||
|
||||
val quality = when {
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
|
||||
when {
|
||||
downstreamSpeedKbps > 50000 -> NetworkQuality.GOOD
|
||||
downstreamSpeedKbps > 10000 -> NetworkQuality.MODERATE
|
||||
else -> NetworkQuality.POOR
|
||||
}
|
||||
}
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
|
||||
when {
|
||||
downstreamSpeedKbps > 5000 -> NetworkQuality.GOOD
|
||||
downstreamSpeedKbps > 1000 -> NetworkQuality.MODERATE
|
||||
else -> NetworkQuality.POOR
|
||||
}
|
||||
}
|
||||
else -> NetworkQuality.UNKNOWN
|
||||
}
|
||||
|
||||
networkQualityCallback?.invoke(quality)
|
||||
}
|
||||
|
||||
fun setNetworkQualityListener(callback: (NetworkQuality) -> Unit) {
|
||||
networkQualityCallback = callback
|
||||
}
|
||||
|
||||
suspend fun getRadioBrowserServerSuspended(): String {
|
||||
if (!isConnectedToNetwork()) {
|
||||
throw IOException("No internet connection")
|
||||
}
|
||||
return suspendCoroutine { cont ->
|
||||
try {
|
||||
val serverAddressList: Array<InetAddress> = InetAddress.getAllByName(Keys.RADIO_BROWSER_API_BASE)
|
||||
val serverAddress = serverAddressList[Random().nextInt(serverAddressList.size)].canonicalHostName
|
||||
PreferencesHelper.saveRadioBrowserApiAddress(serverAddress)
|
||||
cont.resume(serverAddress)
|
||||
} catch (e: UnknownHostException) {
|
||||
Timber.tag(TAG).e("Error resolving server address: ${e.message}")
|
||||
cont.resumeWithException(IOException("Unable to resolve the server address.", e))
|
||||
} catch (e: SecurityException) {
|
||||
Timber.tag(TAG).e("Security exception: ${e.message}")
|
||||
cont.resumeWithException(IOException("Security error while resolving the server address.", e))
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Timber.tag(TAG).e("Socket timeout occurred: ${e.message}")
|
||||
cont.resumeWithException(IOException("Request timed out while resolving the server address.", e))
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("General exception: ${e.message}")
|
||||
cont.resumeWithException(IOException("An unexpected error occurred.", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
class NetworkMonitor(context: Context) {
|
||||
interface Listener {
|
||||
fun onNetworkAvailable()
|
||||
fun onNetworkLost()
|
||||
}
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val listeners = mutableListOf<Listener>()
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
listeners.forEach { it.onNetworkAvailable() }
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
listeners.forEach { it.onNetworkLost() }
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import com.yaros.RadioUrl.R
|
||||
import timber.log.Timber
|
||||
import java.lang.ref.WeakReference
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
|
||||
object PermissionHelper {
|
||||
|
||||
private const val PREF_NAME = "PermissionPrefs"
|
||||
private const val PREF_UNIFIED_DIALOG_SHOWN = "unified_dialog_shown"
|
||||
private const val MAX_DIALOG_SHOW_ATTEMPTS = 2
|
||||
|
||||
fun checkEssentialPermissions(activity: Activity) {
|
||||
val prefs = activity.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
if (shouldShowDialog(activity, prefs)) {
|
||||
showPermissionExplanationDialog(activity, prefs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldShowDialog(context: Context, prefs: SharedPreferences): Boolean {
|
||||
return !prefs.getBoolean(PREF_UNIFIED_DIALOG_SHOWN, false) &&
|
||||
(needsNotificationPermission(context) || needsBatteryOptimization(context))
|
||||
}
|
||||
|
||||
private fun needsNotificationPermission(context: Context): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
private fun needsBatteryOptimization(context: Context): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||
(context.getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(context.packageName) == false)
|
||||
}
|
||||
|
||||
private fun showPermissionExplanationDialog(activity: Activity, prefs: SharedPreferences) {
|
||||
val dialogShownCount = prefs.getInt("${PREF_UNIFIED_DIALOG_SHOWN}_count", 0)
|
||||
if (dialogShownCount >= MAX_DIALOG_SHOW_ATTEMPTS) return
|
||||
|
||||
val weakActivity = WeakReference(activity)
|
||||
AlertDialog.Builder(activity).apply {
|
||||
setTitle(R.string.permission_required_title)
|
||||
setMessage(buildExplanationMessage(activity))
|
||||
setPositiveButton(R.string.grant_permission) { dialog, _ ->
|
||||
logEvent("permission_dialog_accepted", mapOf(
|
||||
"attempt" to dialogShownCount,
|
||||
"permissions" to getRequiredPermissions(activity)
|
||||
))
|
||||
weakActivity.get()?.let { handlePermissionGrant(it, prefs, dialogShownCount) }
|
||||
dialog.dismiss()
|
||||
}
|
||||
setNegativeButton(R.string.remind_later) { dialog, _ ->
|
||||
logEvent("permission_dialog_deferred", mapOf(
|
||||
"attempt" to dialogShownCount,
|
||||
"permissions" to getRequiredPermissions(activity)
|
||||
))
|
||||
prefs.edit { putInt("${PREF_UNIFIED_DIALOG_SHOWN}_count", dialogShownCount + 1) }
|
||||
dialog.dismiss()
|
||||
}
|
||||
setOnDismissListener {
|
||||
logEvent("permission_dialog_dismissed")
|
||||
}
|
||||
setCancelable(false)
|
||||
show()
|
||||
|
||||
logEvent("permission_dialog_shown", mapOf(
|
||||
"attempt" to dialogShownCount,
|
||||
"permissions" to getRequiredPermissions(activity))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildExplanationMessage(context: Context): String {
|
||||
val sb = StringBuilder()
|
||||
if (needsNotificationPermission(context)) {
|
||||
sb.append("\u2022 ${context.getString(R.string.notification_permission_explanation)}\n")
|
||||
}
|
||||
if (needsBatteryOptimization(context)) {
|
||||
sb.append("\u2022 ${context.getString(R.string.battery_optimization_explanation)}\n")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getRequiredPermissions(context: Context): String {
|
||||
return listOfNotNull(
|
||||
if (needsNotificationPermission(context)) "notifications" else null,
|
||||
if (needsBatteryOptimization(context)) "battery" else null
|
||||
).joinToString()
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun handlePermissionGrant(activity: Activity, prefs: SharedPreferences, attempt: Int) {
|
||||
val permissions = mutableListOf<String>().apply {
|
||||
if (needsNotificationPermission(activity)) add("notifications")
|
||||
if (needsBatteryOptimization(activity)) add("battery")
|
||||
}
|
||||
|
||||
if (permissions.isNotEmpty()) {
|
||||
requestPermissionsSequentially(activity, permissions, 0)
|
||||
}
|
||||
|
||||
// Помечаем как показанный только если все разрешения получены
|
||||
if (!needsNotificationPermission(activity) && !needsBatteryOptimization(activity)) {
|
||||
prefs.edit { putBoolean(PREF_UNIFIED_DIALOG_SHOWN, true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissionsSequentially(activity: Activity, permissions: List<String>, index: Int) {
|
||||
if (index >= permissions.size) {
|
||||
logFinalResults(activity)
|
||||
return
|
||||
}
|
||||
|
||||
when (permissions[index]) {
|
||||
// "notifications" -> requestNotificationPermission(activity) {
|
||||
// logPermissionResult("notifications", isNotificationGranted(activity))
|
||||
// requestPermissionsSequentially(activity, permissions, index + 1)
|
||||
// }
|
||||
"battery" -> requestBatteryOptimization(activity) {
|
||||
logPermissionResult("battery", isBatteryOptimizationDisabled(activity))
|
||||
requestPermissionsSequentially(activity, permissions, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logFinalResults(context: Context) {
|
||||
val granted = mutableListOf<String>()
|
||||
val denied = mutableListOf<String>()
|
||||
|
||||
if (isNotificationGranted(context)) granted.add("notifications") else denied.add("notifications")
|
||||
if (isBatteryOptimizationDisabled(context)) granted.add("battery") else denied.add("battery")
|
||||
|
||||
logEvent("permission_final_results", mapOf(
|
||||
"granted" to granted.joinToString(),
|
||||
"denied" to denied.joinToString())
|
||||
)
|
||||
}
|
||||
|
||||
// private fun requestNotificationPermission(activity: Activity, onComplete: () -> Unit) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// val launcher = activity.registerForActivityResult(
|
||||
// ActivityResultContracts.RequestPermission()
|
||||
// ) { granted: Boolean ->
|
||||
// if (!granted) showCustomNotificationSettings(activity)
|
||||
// onComplete()
|
||||
// }
|
||||
// launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
// } else {
|
||||
// onComplete()
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun showCustomNotificationSettings(activity: Activity) {
|
||||
try {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
|
||||
activity.startActivity(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to open notification settings")
|
||||
openAppSettings(activity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBatteryOptimization(activity: Activity, onComplete: () -> Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${activity.packageName}".toUri()
|
||||
activity.startActivity(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
openBatteryOptimizationSettings(activity)
|
||||
}
|
||||
}
|
||||
onComplete()
|
||||
}
|
||||
|
||||
private fun openBatteryOptimizationSettings(context: Context) {
|
||||
try {
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||
context.startActivity(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = "package:${context.packageName}".toUri()
|
||||
context.startActivity(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logEvent(event: String, params: Map<String, Any?> = emptyMap()) {
|
||||
// Логирование событий через Timber (Firebase опционален)
|
||||
Timber.d("Permission event: $event, params: $params")
|
||||
}
|
||||
|
||||
private fun logPermissionResult(permission: String, granted: Boolean) {
|
||||
logEvent("permission_result", mapOf(
|
||||
"permission" to permission,
|
||||
"granted" to granted)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isNotificationGranted(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
} else true
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationDisabled(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
context.getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(context.packageName) ?: false
|
||||
} else true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.ui.PlayerState
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
|
||||
object PreferencesHelper {
|
||||
|
||||
private val TAG: String = PreferencesHelper::class.java.simpleName
|
||||
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
fun Context.initPreferences() {
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
}
|
||||
|
||||
fun loadRadioBrowserApiAddress(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_RADIO_BROWSER_API,
|
||||
Keys.RADIO_BROWSER_API_DEFAULT
|
||||
) ?: Keys.RADIO_BROWSER_API_DEFAULT
|
||||
}
|
||||
|
||||
fun saveRadioBrowserApiAddress(radioBrowserApi: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_RADIO_BROWSER_API, radioBrowserApi)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveIsPlaying(isPlaying: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadStationListStreamUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_STATION_LIST_EXPANDED_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
fun saveStationListStreamUuid(stationUuid: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_STATION_LIST_EXPANDED_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastUpdateCollection(lastUpdate: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_LAST_UPDATE_COLLECTION, DateTimeHelper.convertToRfc2822(lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCollectionSize(): Int {
|
||||
return sharedPreferences.getInt(Keys.PREF_COLLECTION_SIZE, -1)
|
||||
}
|
||||
|
||||
fun saveCollectionSize(size: Int) {
|
||||
sharedPreferences.edit {
|
||||
putInt(Keys.PREF_COLLECTION_SIZE, size)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSleepTimerRunning(isRunning: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, isRunning)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCollectionModificationDate(): Date {
|
||||
val modificationDateString: String =
|
||||
sharedPreferences.getString(Keys.PREF_COLLECTION_MODIFICATION_DATE, "") ?: String()
|
||||
return DateTimeHelper.convertFromRfc2822(modificationDateString)
|
||||
}
|
||||
|
||||
fun saveCollectionModificationDate(lastSave: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(
|
||||
Keys.PREF_COLLECTION_MODIFICATION_DATE,
|
||||
DateTimeHelper.convertToRfc2822(lastSave)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadActiveDownloads(): String {
|
||||
val activeDownloadsString: String =
|
||||
sharedPreferences.getString(Keys.PREF_ACTIVE_DOWNLOADS, Keys.ACTIVE_DOWNLOADS_EMPTY)
|
||||
?: Keys.ACTIVE_DOWNLOADS_EMPTY
|
||||
Timber.tag(TAG).v("IDs of active downloads: $activeDownloadsString")
|
||||
return activeDownloadsString
|
||||
}
|
||||
|
||||
fun saveActiveDownloads(activeDownloadsString: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_ACTIVE_DOWNLOADS, activeDownloadsString)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCurrentStationId(stationUuid: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_STATE_STATION_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLastPlayedStationUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
fun saveMetadataHistory(metadataHistory: MutableList<String>) {
|
||||
val gson = Gson()
|
||||
val json = gson.toJson(metadataHistory)
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_METADATA_HISTORY, json)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun registerPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unregisterPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun isHouseKeepingNecessary(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, true)
|
||||
}
|
||||
|
||||
fun saveHouseKeepingNecessaryState(state: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, state)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadThemeSelection(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_THEME_SELECTION,
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
) ?: Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
}
|
||||
|
||||
fun loadEditStationsEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STATIONS, true)
|
||||
}
|
||||
|
||||
fun saveEditStationsEnabled(enabled: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_EDIT_STATIONS, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEditStreamUrisEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STREAMS_URIS, true)
|
||||
}
|
||||
|
||||
fun loadLargeBufferSize(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)
|
||||
}
|
||||
|
||||
fun loadBufferSizeMultiplier(): Int {
|
||||
return if (sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)) {
|
||||
Keys.LARGE_BUFFER_SIZE_MULTIPLIER
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadOverMobile(): Boolean {
|
||||
return sharedPreferences.getBoolean(
|
||||
Keys.PREF_DOWNLOAD_OVER_MOBILE,
|
||||
Keys.DEFAULT_DOWNLOAD_OVER_MOBILE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.MainActivity
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
|
||||
|
||||
object ShortcutHelper {
|
||||
|
||||
fun placeShortcut(context: Context, station: Station) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.yaros.RadioUrl.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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
|
||||
object UiHelper {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSoftKeyboard(context: Context, view: View) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.ui.search.RadioBrowserResult
|
||||
import com.yaros.RadioUrl.ui.search.RadioBrowserSearch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import timber.log.Timber
|
||||
|
||||
class UpdateHelper(
|
||||
private val context: Context,
|
||||
private val updateHelperListener: UpdateHelperListener,
|
||||
private var collection: Collection
|
||||
) : RadioBrowserSearch.RadioBrowserSearchListener {
|
||||
|
||||
|
||||
private val TAG: String = UpdateHelper::class.java.simpleName
|
||||
|
||||
private var radioBrowserSearchCounter: Int = 0
|
||||
private var remoteStationLocationsList: MutableList<String> = mutableListOf()
|
||||
|
||||
interface UpdateHelperListener {
|
||||
fun onStationUpdated(
|
||||
collection: Collection,
|
||||
positionPriorUpdate: Int,
|
||||
positionAfterUpdate: Int
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
|
||||
if (results.isNotEmpty()) {
|
||||
CoroutineScope(IO).launch {
|
||||
val station: Station = results[0].toStation()
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
station.streamContent = contentType.type
|
||||
val positionPriorUpdate =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
val positionAfterUpdate: Int =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
withContext(Main) {
|
||||
updateHelperListener.onStationUpdated(
|
||||
collection,
|
||||
positionPriorUpdate,
|
||||
positionAfterUpdate
|
||||
)
|
||||
}
|
||||
radioBrowserSearchCounter--
|
||||
if (radioBrowserSearchCounter == 0 && remoteStationLocationsList.isNotEmpty()) {
|
||||
DownloadHelper.downloadPlaylists(
|
||||
context,
|
||||
remoteStationLocationsList.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateCollection() {
|
||||
PreferencesHelper.saveLastUpdateCollection()
|
||||
collection.stations.forEach { station ->
|
||||
when {
|
||||
station.radioBrowserStationUuid.isNotEmpty() -> {
|
||||
radioBrowserSearchCounter++
|
||||
downloadFromRadioBrowser(station.radioBrowserStationUuid)
|
||||
}
|
||||
station.remoteStationLocation.isNotEmpty() -> {
|
||||
remoteStationLocationsList.add(station.remoteStationLocation)
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(TAG).w("Unable to update station: ${station.name}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (radioBrowserSearchCounter == 0) {
|
||||
DownloadHelper.downloadPlaylists(context, remoteStationLocationsList.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun downloadFromRadioBrowser(radioBrowserStationUuid: String) {
|
||||
val radioBrowserSearch = RadioBrowserSearch(this)
|
||||
radioBrowserSearch.searchStation(context, radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.yaros.RadioUrl.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Helper для управления индивидуальными настройками громкости радиостанций
|
||||
* Громкость хранится отдельно от модели Station в SharedPreferences
|
||||
*/
|
||||
object VolumeSettingsHelper {
|
||||
|
||||
private const val PREFS_NAME = "station_volume_settings"
|
||||
private const val TAG = "VolumeSettingsHelper"
|
||||
private const val DEFAULT_VOLUME = 1.0f // Максимальная громкость по умолчанию
|
||||
|
||||
/**
|
||||
* Получить настройку громкости для станции
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
* @return Значение громкости от 0.0 до 1.0
|
||||
*/
|
||||
fun getVolume(context: Context, stationUuid: String): Float {
|
||||
val prefs = getPreferences(context)
|
||||
val volume = prefs.getFloat(stationUuid, DEFAULT_VOLUME)
|
||||
val hasCustom = prefs.contains(stationUuid)
|
||||
Timber.tag(TAG).i("Getting volume for station $stationUuid: $volume (hasCustom: $hasCustom, default: $DEFAULT_VOLUME)")
|
||||
return volume
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить настройку громкости для станции
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
* @param volume Значение громкости от 0.0 до 1.0
|
||||
*/
|
||||
fun setVolume(context: Context, stationUuid: String, volume: Float) {
|
||||
val normalizedVolume = volume.coerceIn(0.0f, 1.0f)
|
||||
val prefs = getPreferences(context)
|
||||
prefs.edit().putFloat(stationUuid, normalizedVolume).apply()
|
||||
Timber.tag(TAG).i("Setting volume for station $stationUuid: $normalizedVolume (${(normalizedVolume * 100).toInt()}%)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить настройку громкости для станции (вернуть к значению по умолчанию)
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
*/
|
||||
fun removeVolume(context: Context, stationUuid: String) {
|
||||
val prefs = getPreferences(context)
|
||||
prefs.edit().remove(stationUuid).apply()
|
||||
Timber.tag(TAG).d("Removing volume setting for station $stationUuid")
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, есть ли индивидуальная настройка громкости для станции
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
* @return true если есть индивидуальная настройка
|
||||
*/
|
||||
fun hasCustomVolume(context: Context, stationUuid: String): Boolean {
|
||||
val prefs = getPreferences(context)
|
||||
return prefs.contains(stationUuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все настройки громкости
|
||||
* @param context Context приложения
|
||||
* @return Map с UUID станций и их громкостью
|
||||
*/
|
||||
fun getAllVolumeSettings(context: Context): Map<String, Float> {
|
||||
val prefs = getPreferences(context)
|
||||
val allSettings = mutableMapOf<String, Float>()
|
||||
prefs.all.forEach { (key, value) ->
|
||||
if (value is Float) {
|
||||
allSettings[key] = value
|
||||
}
|
||||
}
|
||||
return allSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все настройки громкости
|
||||
* @param context Context приложения
|
||||
*/
|
||||
fun clearAllVolumeSettings(context: Context) {
|
||||
val prefs = getPreferences(context)
|
||||
prefs.edit().clear().apply()
|
||||
Timber.tag(TAG).d("Cleared all volume settings")
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить процентное значение громкости (0-100)
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
* @return Значение от 0 до 100
|
||||
*/
|
||||
fun getVolumePercent(context: Context, stationUuid: String): Int {
|
||||
return (getVolume(context, stationUuid) * 100).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить громкость в процентах (0-100)
|
||||
* @param context Context приложения
|
||||
* @param stationUuid UUID станции
|
||||
* @param percent Значение от 0 до 100
|
||||
*/
|
||||
fun setVolumePercent(context: Context, stationUuid: String, percent: Int) {
|
||||
val normalizedPercent = percent.coerceIn(0, 100)
|
||||
val volume = normalizedPercent / 100.0f
|
||||
Timber.tag(TAG).i("setVolumePercent called for station $stationUuid: $percent% -> $volume")
|
||||
setVolume(context, stationUuid, volume)
|
||||
}
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yaros.RadioUrl.models
|
||||
|
||||
data class CategoryWithCount(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val stationCount: Int,
|
||||
val imageUrl: String?
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yaros.RadioUrl.models
|
||||
|
||||
data class RadioStation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val frequency: String,
|
||||
val imageRes: Int,
|
||||
val color: String
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.yaros.RadioUrl.ui.FavoriteSong
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.data.Song
|
||||
|
||||
class FavoriteAdapter(
|
||||
private val onItemClicked: (Song) -> Unit,
|
||||
private val onItemDeleteClicked: (Song) -> Unit // Renamed for clarity
|
||||
) : ListAdapter<Song, FavoriteAdapter.FavoriteViewHolder>(DIFF_CALLBACK) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteViewHolder {
|
||||
val itemView = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.song_item, parent, false)
|
||||
return FavoriteViewHolder(itemView, onItemClicked, onItemDeleteClicked)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: FavoriteViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class FavoriteViewHolder(
|
||||
itemView: View,
|
||||
private val onClick: (Song) -> Unit,
|
||||
private val onDeleteClick: (Song) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
|
||||
private val artistTextView: TextView = itemView.findViewById(R.id.artistTextView)
|
||||
private val radioStationTextView: TextView = itemView.findViewById(R.id.stationTextView)
|
||||
|
||||
// Reference to the delete button
|
||||
private val deleteButton: Button = itemView.findViewById(R.id.deleteButton)
|
||||
|
||||
private lateinit var currentSong: Song
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
if (::currentSong.isInitialized) {
|
||||
onClick(currentSong)
|
||||
}
|
||||
}
|
||||
// Set OnClickListener for the delete button
|
||||
deleteButton.setOnClickListener {
|
||||
if (::currentSong.isInitialized) {
|
||||
onDeleteClick(currentSong)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(song: Song) {
|
||||
currentSong = song
|
||||
titleTextView.text = song.title
|
||||
artistTextView.text = song.artist
|
||||
radioStationTextView.text = song.radioStation
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Song>() {
|
||||
override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean {
|
||||
return oldItem.title == newItem.title &&
|
||||
oldItem.artist == newItem.artist &&
|
||||
|
||||
oldItem.radioStation == newItem.radioStation
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yaros.RadioUrl.ui.FavoriteSong
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.data.Song
|
||||
|
||||
class FavoriteFragment : Fragment() {
|
||||
|
||||
private val songViewModel: SongViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_favorite_tracks, container, false)
|
||||
val recyclerView: RecyclerView = view.findViewById(R.id.favoriteTracksRecyclerView)
|
||||
val adapter = FavoriteAdapter(
|
||||
onItemClicked = { song -> copyToClipboard(song) },
|
||||
onItemDeleteClicked = { song -> songViewModel.removeFavoriteSong(song) }
|
||||
)
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
// Подписка на изменения избранных песен
|
||||
songViewModel.favoriteSongs.observe(viewLifecycleOwner) { favoriteSongs ->
|
||||
adapter.submitList(favoriteSongs)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun copyToClipboard(song: Song) {
|
||||
val clipboard = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Favorite Song", "${song.title} - ${song.artist}")
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, R.string.toastmessage_copied_to_clipboard, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yaros.RadioUrl.ui.FavoriteSong
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.yaros.RadioUrl.data.Song
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class SongViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val sharedPreferences =
|
||||
application.getSharedPreferences("favorite_songs_prefs", Context.MODE_PRIVATE)
|
||||
private val songsLiveData = MutableLiveData<List<Song>>()
|
||||
|
||||
init {
|
||||
loadFavoriteSongs()
|
||||
}
|
||||
|
||||
val favoriteSongs: LiveData<List<Song>> get() = songsLiveData
|
||||
|
||||
fun addFavoriteSong(song: Song) {
|
||||
val updatedList = songsLiveData.value.orEmpty().toMutableList().apply { add(song) }
|
||||
songsLiveData.value = updatedList
|
||||
saveFavoriteSongs(updatedList)
|
||||
}
|
||||
|
||||
fun removeFavoriteSong(song: Song) {
|
||||
val updatedList = songsLiveData.value.orEmpty().toMutableList().apply { remove(song) }
|
||||
songsLiveData.value = updatedList
|
||||
saveFavoriteSongs(updatedList)
|
||||
}
|
||||
|
||||
private fun loadFavoriteSongs() {
|
||||
val json = sharedPreferences.getString("favorite_songs", "[]")
|
||||
val type: Type = object : TypeToken<List<Song>>() {}.type
|
||||
val songs: List<Song> = Gson().fromJson(json, type)
|
||||
songsLiveData.value = songs
|
||||
}
|
||||
|
||||
private fun saveFavoriteSongs(songs: List<Song>) {
|
||||
val json = Gson().toJson(songs)
|
||||
sharedPreferences.edit().putString("favorite_songs", json).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.adapters.CategoryAdapter
|
||||
import com.yaros.RadioUrl.adapters.NewsAdapter
|
||||
import com.yaros.RadioUrl.adapters.RadioStationAdapter
|
||||
import com.yaros.RadioUrl.adapters.RecommendedStationAdapter
|
||||
import com.yaros.RadioUrl.core.supabase.SupabaseApiRepository
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
|
||||
import com.yaros.RadioUrl.data.Station
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class HomeFragment : Fragment() {
|
||||
|
||||
private lateinit var recommendedStationsRecyclerView: RecyclerView
|
||||
private lateinit var categoriesRecyclerView: RecyclerView
|
||||
private lateinit var newsRecyclerView: RecyclerView
|
||||
private lateinit var recentlyPlayedRecyclerView: RecyclerView
|
||||
|
||||
private lateinit var recommendedStationAdapter: RecommendedStationAdapter
|
||||
private lateinit var categoryAdapter: CategoryAdapter
|
||||
private lateinit var newsAdapter: NewsAdapter
|
||||
private lateinit var stationAdapter: RadioStationAdapter
|
||||
|
||||
private val apiRepository by lazy { SupabaseApiRepository() }
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_home, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
initViews(view)
|
||||
setupAdapters()
|
||||
loadDataFromAPI()
|
||||
}
|
||||
|
||||
private fun initViews(view: View) {
|
||||
recommendedStationsRecyclerView = view.findViewById(R.id.recommendedStationsRecyclerView)
|
||||
categoriesRecyclerView = view.findViewById(R.id.categoriesRecyclerView)
|
||||
newsRecyclerView = view.findViewById(R.id.newsRecyclerView)
|
||||
recentlyPlayedRecyclerView = view.findViewById(R.id.recentlyPlayedRecyclerView)
|
||||
}
|
||||
|
||||
private fun setupAdapters() {
|
||||
setupRecommendedStationsRecyclerView()
|
||||
setupCategoriesRecyclerView()
|
||||
setupNewsRecyclerView()
|
||||
setupRecentlyPlayedRecyclerView()
|
||||
}
|
||||
|
||||
private fun loadDataFromAPI() {
|
||||
lifecycleScope.launch {
|
||||
fetchHomeData()
|
||||
fetchCategories()
|
||||
fetchRecentStations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecommendedStationsRecyclerView() {
|
||||
recommendedStationAdapter = RecommendedStationAdapter(
|
||||
imageLoader = { imageView: ImageView, url: String ->
|
||||
// TODO: Реализация загрузки изображения (Glide/Picasso)
|
||||
},
|
||||
onItemClick = { station: Station ->
|
||||
// TODO: Обработка клика
|
||||
}
|
||||
)
|
||||
recommendedStationsRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
adapter = recommendedStationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupCategoriesRecyclerView() {
|
||||
categoryAdapter = CategoryAdapter(
|
||||
imageLoader = { imageView: ImageView, url: String ->
|
||||
// TODO: Реализация загрузки изображения
|
||||
},
|
||||
onItemClick = { category: Category ->
|
||||
// TODO: Обработка клика
|
||||
}
|
||||
)
|
||||
categoriesRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
adapter = categoryAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNewsRecyclerView() {
|
||||
newsAdapter = NewsAdapter(
|
||||
imageLoader = { imageView: ImageView, url: String ->
|
||||
// TODO: Реализация загрузки изображения
|
||||
},
|
||||
onItemClick = { news: NewsItem ->
|
||||
// TODO: Обработка клика
|
||||
}
|
||||
)
|
||||
newsRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = newsAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecentlyPlayedRecyclerView() {
|
||||
stationAdapter = RadioStationAdapter(
|
||||
imageLoader = { imageView: ImageView, url: String ->
|
||||
// TODO: Реализация загрузки изображения
|
||||
},
|
||||
onItemClick = { station: Station ->
|
||||
// TODO: Обработка клика
|
||||
}
|
||||
)
|
||||
recentlyPlayedRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = stationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchHomeData() {
|
||||
when (val result = apiRepository.getHomeData()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
result.data.news?.let(newsAdapter::submitList)
|
||||
result.data.favorites?.let(recommendedStationAdapter::submitList)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
Timber.e("Home data error: ${result.message}")
|
||||
// Показать состояние ошибки
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCategories() {
|
||||
when (val result = apiRepository.getCategories()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
categoryAdapter.submitList(result.data)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
Timber.e("Categories error: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchRecentStations() {
|
||||
when (val result = apiRepository.getRecentRadio()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
stationAdapter.submitList(result.data)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
Timber.e("Recent stations error: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.yaros.RadioUrl.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.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.data.Song
|
||||
import com.yaros.RadioUrl.helpers.DateTimeHelper
|
||||
import com.yaros.RadioUrl.helpers.ImageHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.UiHelper
|
||||
import com.yaros.RadioUrl.ui.FavoriteSong.SongViewModel
|
||||
import timber.log.Timber
|
||||
|
||||
data class LayoutHolder(var rootView: View) {
|
||||
|
||||
var recyclerView: RecyclerView = rootView.findViewById(R.id.station_list)
|
||||
val layoutManager: LinearLayoutManager
|
||||
private var bottomSheet: ConstraintLayout = rootView.findViewById(R.id.bottom_sheet)
|
||||
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)
|
||||
private var favoriteButton: ImageButton = rootView.findViewById(R.id.favorite_button)
|
||||
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
|
||||
private val songViewModel: SongViewModel by lazy {
|
||||
ViewModelProvider(rootView.context as ViewModelStoreOwner).get(SongViewModel::class.java)
|
||||
}
|
||||
|
||||
init {
|
||||
metadataHistory = PreferencesHelper.loadMetadataHistory()
|
||||
metadataHistoryPosition = metadataHistory.size - 1
|
||||
isBuffering = false
|
||||
|
||||
layoutManager = CustomLayoutManager(rootView.context)
|
||||
recyclerView.layoutManager = layoutManager
|
||||
recyclerView.itemAnimator = DefaultItemAnimator()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
setupBottomSheet()
|
||||
}
|
||||
|
||||
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
|
||||
|
||||
if (!isPlaying) {
|
||||
metadataView.text = station.name
|
||||
sheetMetadataHistoryView.text = station.name
|
||||
}
|
||||
|
||||
stationNameView.text = station.name
|
||||
|
||||
stationNameView.isSelected = isPlaying
|
||||
|
||||
stationNameView.setFadingEdgeLength(8)
|
||||
|
||||
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}"
|
||||
|
||||
sheetStreamingLinkView.text = station.getStreamUri()
|
||||
|
||||
val bitrateText: CharSequence = if (station.codec.isNotEmpty()) {
|
||||
if (station.bitrate == 0) {
|
||||
station.codec
|
||||
station.language
|
||||
station.country
|
||||
} else {
|
||||
buildString {
|
||||
append(station.codec)
|
||||
append(" | ")
|
||||
append(station.bitrate)
|
||||
append("kbps")
|
||||
append(" | ")
|
||||
append(station.language)
|
||||
append(" | ")
|
||||
append(station.country)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
sheetBitrateView.text = bitrateText
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
favoriteButton.setOnClickListener{
|
||||
onFavoriteButtonClick(
|
||||
context,
|
||||
sheetMetadataHistoryView.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)
|
||||
}
|
||||
val logMessage = buildString {
|
||||
append("UUID: ${station.uuid}\n")
|
||||
append("Starred: ${station.starred}\n")
|
||||
append("Name: ${station.name}\n")
|
||||
append("Name Manually Set: ${station.nameManuallySet}\n")
|
||||
append("Stream URIs: ${station.streamUris.joinToString(", ")}\n")
|
||||
append("Current Stream: ${station.getStreamUri()}\n")
|
||||
append("Stream Content: ${station.streamContent}\n")
|
||||
append("Homepage: ${station.homepage}\n")
|
||||
append("Image: ${station.image}\n")
|
||||
append("Small Image: ${station.smallImage}\n")
|
||||
append("Image Color: ${station.imageColor}\n")
|
||||
append("Image Manually Set: ${station.imageManuallySet}\n")
|
||||
append("Remote Image Location: ${station.remoteImageLocation}\n")
|
||||
append("Remote Station Location: ${station.remoteStationLocation}\n")
|
||||
append("Modification Date: ${station.modificationDate}\n")
|
||||
append("Is Playing: ${station.isPlaying}\n")
|
||||
append("Radio Browser Station UUID: ${station.radioBrowserStationUuid}\n")
|
||||
append("Radio Browser Change UUID: ${station.radioBrowserChangeUuid}\n")
|
||||
append("Bitrate: ${station.bitrate}\n")
|
||||
append("Codec: ${station.codec}\n")
|
||||
append("Country Code: ${station.countrycode}\n")
|
||||
append("Country: ${station.country}\n")
|
||||
append("Language: ${station.language}\n")
|
||||
append("Language Codes: ${station.languagecodes}\n")
|
||||
}
|
||||
Timber.tag("StationDetails").d(logMessage)
|
||||
}
|
||||
|
||||
private fun onFavoriteButtonClick(context: Context, text: CharSequence) {
|
||||
// Логирование текста для проверки
|
||||
Timber.tag("onFavoriteButtonClick").d("Received text: $text")
|
||||
|
||||
// Разбор строки по шаблону "Artist - Title"
|
||||
val parts = text.split(" - ")
|
||||
if (parts.size == 2) {
|
||||
val artist = parts[0].trim()
|
||||
val title = parts[1].trim()
|
||||
val radioStation = "" // Дополнительную информацию можно оставить пустой или задать дефолтным значением
|
||||
|
||||
if (artist.isNotEmpty() && title.isNotEmpty()) {
|
||||
val song = Song(
|
||||
title = title,
|
||||
artist = artist,
|
||||
radioStation = radioStation
|
||||
)
|
||||
songViewModel.addFavoriteSong(song)
|
||||
Toast.makeText(context, R.string.toastaddsong, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
// Логирование ошибки для отладки
|
||||
Timber.tag("onFavoriteButtonClick").e("Empty artist or title: $text")
|
||||
Toast.makeText(context, R.string.toastfailedsong, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
// Логирование ошибки для отладки
|
||||
Timber.tag("onFavoriteButtonClick").e("Incorrect data format: $text")
|
||||
Toast.makeText(context, R.string.toastfailedsong, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
favoriteButton.isVisible = true
|
||||
} else {
|
||||
playButtonView.setImageResource(R.drawable.ic_player_play_symbol_42dp)
|
||||
sheetSleepTimerStartButtonView.isVisible = false
|
||||
favoriteButton.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
fun showBufferingIndicator(buffering: Boolean) {
|
||||
bufferingIndicator.isVisible = buffering
|
||||
isBuffering = buffering
|
||||
}
|
||||
|
||||
// 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)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun toggleDownloadProgressIndicator() {
|
||||
when (PreferencesHelper.loadActiveDownloads()) {
|
||||
Keys.ACTIVE_DOWNLOADS_EMPTY -> downloadProgressIndicator.isGone = true
|
||||
else -> downloadProgressIndicator.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun showPlayer(context: Context): Boolean {
|
||||
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, Keys.BOTTOM_SHEET_PEEK_HEIGHT)
|
||||
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_HIDDEN && onboardingLayout.isGone) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hidePlayer(context: Context): Boolean {
|
||||
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, 0)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return true
|
||||
}
|
||||
|
||||
fun minimizePlayerIfExpanded(): Boolean {
|
||||
return if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAnimationListener(isPlaying: Boolean): Animation.AnimationListener {
|
||||
return object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation) {}
|
||||
override fun onAnimationEnd(animation: Animation) {
|
||||
togglePlayButton(isPlaying)
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animation) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBottomSheet() {
|
||||
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
|
||||
BottomSheetBehavior.STATE_DRAGGING -> Unit
|
||||
BottomSheetBehavior.STATE_EXPANDED -> Unit
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> Unit
|
||||
BottomSheetBehavior.STATE_SETTLING -> Unit
|
||||
BottomSheetBehavior.STATE_HIDDEN -> showPlayer(rootView.context)
|
||||
}
|
||||
}
|
||||
})
|
||||
bottomSheet.setOnClickListener { toggleBottomSheetState() }
|
||||
stationImageView.setOnClickListener { toggleBottomSheetState() }
|
||||
stationNameView.setOnClickListener { toggleBottomSheetState() }
|
||||
metadataView.setOnClickListener { toggleBottomSheetState() }
|
||||
}
|
||||
|
||||
private fun toggleBottomSheetState() {
|
||||
when (bottomSheetBehavior.state) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> bottomSheetBehavior.state =
|
||||
BottomSheetBehavior.STATE_EXPANDED
|
||||
else -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CustomLayoutManager(context: Context) :
|
||||
LinearLayoutManager(context, VERTICAL, false) {
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import android.widget.Button
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
|
||||
class MenuFragment : Fragment() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_menu, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Найти включенный макет
|
||||
val placeholderLayout = view.findViewById<View>(R.id.placeholder)
|
||||
if (placeholderLayout != null) {
|
||||
val settingsButton: Button = placeholderLayout.findViewById(R.id.settingsButton)
|
||||
settingsButton.setOnClickListener {
|
||||
performSettings()
|
||||
}
|
||||
}
|
||||
val placeholderLayouts = view.findViewById<View>(R.id.placeholdetwo)
|
||||
if (placeholderLayouts != null) {
|
||||
val favoriteButton: Button = placeholderLayouts.findViewById(R.id.favoriteButton)
|
||||
favoriteButton.setOnClickListener{
|
||||
performFavorite()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSettings() {
|
||||
findNavController().navigate(R.id.action_navigation_menu_to_settingsFragment)
|
||||
}
|
||||
private fun performFavorite(){
|
||||
findNavController().navigate(R.id.action_navigation_menu_to_navigation_favorite)
|
||||
}
|
||||
|
||||
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_THEME_SELECTION -> {
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
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.common.util.UnstableApi
|
||||
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.RequestQueue
|
||||
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.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.PlayerService
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.collection.CollectionAdapter
|
||||
import com.yaros.RadioUrl.core.collection.CollectionViewModel
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.core.dialogs.AddStationDialog
|
||||
import com.yaros.RadioUrl.core.dialogs.FindStationDialog
|
||||
import com.yaros.RadioUrl.core.dialogs.YesNoDialog
|
||||
import com.yaros.RadioUrl.core.extensions.cancelSleepTimer
|
||||
import com.yaros.RadioUrl.core.extensions.play
|
||||
import com.yaros.RadioUrl.core.extensions.playStreamDirectly
|
||||
import com.yaros.RadioUrl.core.extensions.requestMetadataHistory
|
||||
import com.yaros.RadioUrl.core.extensions.requestSleepTimerRemaining
|
||||
import com.yaros.RadioUrl.core.extensions.startSleepTimer
|
||||
import com.yaros.RadioUrl.helpers.BackupHelper
|
||||
import com.yaros.RadioUrl.helpers.CollectionHelper
|
||||
import com.yaros.RadioUrl.helpers.DownloadHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.UiHelper
|
||||
import com.yaros.RadioUrl.helpers.UpdateHelper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Runnable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
@UnstableApi
|
||||
class PlayerFragment : Fragment(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
FindStationDialog.FindStationDialogListener,
|
||||
AddStationDialog.AddStationDialogListener,
|
||||
CollectionAdapter.CollectionAdapterListener,
|
||||
YesNoDialog.YesNoDialogListener {
|
||||
|
||||
private val TAG: String = PlayerFragment::class.java.simpleName
|
||||
|
||||
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() = try {
|
||||
if (controllerFuture.isDone) controllerFuture.get() else null
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to get media controller")
|
||||
null
|
||||
}
|
||||
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
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (isEnabled && this@PlayerFragment::layout.isInitialized && !layout.minimizePlayerIfExpanded()) {
|
||||
isEnabled = false
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
queue = Volley.newRequestQueue(requireActivity())
|
||||
|
||||
playerState = PreferencesHelper.loadPlayerState()
|
||||
|
||||
collectionViewModel = ViewModelProvider(this)[CollectionViewModel::class.java]
|
||||
|
||||
collectionAdapter = CollectionAdapter(
|
||||
activity as Context,
|
||||
this as CollectionAdapter.CollectionAdapterListener
|
||||
)
|
||||
|
||||
listLayoutState = savedInstanceState?.getParcelable<Parcelable>(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST)
|
||||
|
||||
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 { } }, 5000)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val rootView: View = inflater.inflate(R.layout.fragment_player, container, false)
|
||||
layout = LayoutHolder(rootView)
|
||||
|
||||
initializeViews()
|
||||
|
||||
(activity as AppCompatActivity).supportActionBar?.hide()
|
||||
|
||||
(activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireActivity(),
|
||||
R.color.player_sheet_background
|
||||
)
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback())
|
||||
itemTouchHelper?.attachToRecyclerView(layout.recyclerView)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
inner class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
|
||||
|
||||
override fun isLongPressDragEnabled() = !collectionAdapter.isExpandedForEdit
|
||||
|
||||
override fun isItemViewSwipeEnabled() = true
|
||||
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
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.bindingAdapterPosition
|
||||
val toPosition = target.bindingAdapterPosition
|
||||
collectionAdapter.onItemMove(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
collectionAdapter.onItemDismiss(position)
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
collectionAdapter.saveCollectionAfterDragDrop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
initializeController()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
if (this::layout.isInitialized) {
|
||||
listLayoutState = layout.layoutManager.onSaveInstanceState()
|
||||
outState.putParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST, listLayoutState)
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
activity?.volumeControlStream = AudioManager.STREAM_MUSIC
|
||||
playerState = PreferencesHelper.loadPlayerState()
|
||||
// setupPlaybackControls()
|
||||
updatePlayerViews()
|
||||
updateStationListState()
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
observeCollectionViewModel()
|
||||
handleNavigationArguments()
|
||||
// handleStartIntent()
|
||||
PreferencesHelper.registerPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable)
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
releaseController()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
queue.cancelAll(TAG)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == Keys.PREF_ACTIVE_DOWNLOADS) {
|
||||
layout.toggleDownloadProgressIndicator()
|
||||
}
|
||||
if (key == Keys.PREF_PLAYER_METADATA_HISTORY) {
|
||||
requestMetadataUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFindStationDialog(station: Station) {
|
||||
if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
activity?.let { context ->
|
||||
collection = CollectionHelper.addStation(context, collection, station)
|
||||
}
|
||||
} else {
|
||||
CoroutineScope(IO).launch {
|
||||
try {
|
||||
val contentType: NetworkHelper.ContentType = NetworkHelper.detectContentType(station.getStreamUri())
|
||||
station.streamContent = contentType.type
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error detecting content type: ${e.message}")
|
||||
withContext(Main) {
|
||||
// Add station anyway with unknown content type
|
||||
station.streamContent = Keys.MIME_TYPE_UNSUPPORTED
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddStationDialog(station: Station) {
|
||||
if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayButtonTapped(stationUuid: String) {
|
||||
try {
|
||||
if (controller?.isPlaying == true && stationUuid == playerState.stationUuid) {
|
||||
controller?.pause()
|
||||
} else {
|
||||
activity?.let { context ->
|
||||
val station = CollectionHelper.getStation(collection, stationUuid)
|
||||
if (station != null) {
|
||||
controller?.play(context, station)
|
||||
} else {
|
||||
Timber.tag(TAG).e("Station not found with UUID: $stationUuid")
|
||||
Toast.makeText(context, "Station not found", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error playing station: ${e.message}")
|
||||
Toast.makeText(activity, "Error playing station", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddNewButtonTapped() {
|
||||
FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show()
|
||||
}
|
||||
|
||||
override fun onChangeImageButtonTapped(stationUuid: String) {
|
||||
tempStationUuid = stationUuid
|
||||
pickImage()
|
||||
}
|
||||
|
||||
override fun onYesNoDialog(
|
||||
type: Int,
|
||||
dialogResult: Boolean,
|
||||
payload: Int,
|
||||
payloadString: String
|
||||
) {
|
||||
super.onYesNoDialog(type, dialogResult, payload, payloadString)
|
||||
when (type) {
|
||||
Keys.DIALOG_REMOVE_STATION -> {
|
||||
when (dialogResult) {
|
||||
true -> collectionAdapter.removeStation(activity as Context, payload)
|
||||
false -> collectionAdapter.notifyItemChanged(payload)
|
||||
}
|
||||
}
|
||||
Keys.DIALOG_RESTORE_COLLECTION -> {
|
||||
when (dialogResult) {
|
||||
true -> BackupHelper.restore(requireView(), activity as Context, payloadString.toUri())
|
||||
false -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
private fun releaseController() {
|
||||
MediaController.releaseFuture(controllerFuture)
|
||||
}
|
||||
|
||||
private fun setupController() {
|
||||
val controller: MediaController = this.controller ?: return
|
||||
controller.addListener(playerListener)
|
||||
|
||||
// Получаем актуальное состояние из контроллера
|
||||
playerState.isPlaying = controller.isPlaying
|
||||
playerState.stationUuid = controller.currentMediaItem?.mediaId ?: ""
|
||||
|
||||
// Сохраняем полученное состояние
|
||||
PreferencesHelper.saveIsPlaying(playerState.isPlaying)
|
||||
PreferencesHelper.saveCurrentStationId(playerState.stationUuid)
|
||||
|
||||
updatePlayerViews()
|
||||
requestMetadataUpdate()
|
||||
handleStartIntent()
|
||||
}
|
||||
|
||||
private fun initializeViews() {
|
||||
layout.recyclerView.adapter = collectionAdapter
|
||||
|
||||
val swipeToDeleteHandler = object : UiHelper.SwipeToDeleteCallback(activity as Context) {
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val adapterPosition: Int = viewHolder.bindingAdapterPosition
|
||||
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)
|
||||
|
||||
val swipeToMarkStarredHandler =
|
||||
object : UiHelper.SwipeToMarkStarredCallback(activity as Context) {
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val adapterPosition: Int = viewHolder.bindingAdapterPosition
|
||||
collectionAdapter.toggleStarredStation(activity as Context, adapterPosition)
|
||||
}
|
||||
}
|
||||
val swipeToMarkStarredItemTouchHelper = ItemTouchHelper(swipeToMarkStarredHandler)
|
||||
swipeToMarkStarredItemTouchHelper.attachToRecyclerView(layout.recyclerView)
|
||||
|
||||
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
|
||||
playerState.sleepTimerRunning = true
|
||||
controller?.startSleepTimer(selectedTimeMillis)
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
}
|
||||
|
||||
timePicker.show(requireActivity().supportFragmentManager, "tag")
|
||||
}
|
||||
else -> Snackbar.make(
|
||||
requireView(),
|
||||
R.string.toastmessage_sleep_timer_unable_to_start,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
layout.sheetSleepTimerCancelButtonView.setOnClickListener {
|
||||
playerState.sleepTimerRunning = false
|
||||
controller?.cancelSleepTimer()
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun updatePlayerViews() {
|
||||
var station = Station()
|
||||
if (playerState.stationUuid.isNotEmpty()) {
|
||||
station = CollectionHelper.getStation(collection, playerState.stationUuid)
|
||||
} else if (collection.stations.isNotEmpty()) {
|
||||
station = collection.stations[0]
|
||||
playerState.stationUuid = station.uuid
|
||||
}
|
||||
layout.togglePlayButton(playerState.isPlaying)
|
||||
layout.updatePlayerViews(activity as Context, station, playerState.isPlaying)
|
||||
|
||||
layout.playButtonView.setOnClickListener {
|
||||
onPlayButtonTapped(playerState.stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStationListState() {
|
||||
if (listLayoutState != null) {
|
||||
layout.layoutManager.onRestoreInstanceState(listLayoutState)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
private fun pickImage() {
|
||||
pickSingleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
(activity as Activity).intent.action = ""
|
||||
}
|
||||
|
||||
private fun handleShowPlayer() {
|
||||
Timber.tag(TAG).i("Tap on notification registered.")
|
||||
}
|
||||
|
||||
private fun handleViewIntent() {
|
||||
val intentUri: Uri? = (activity as Activity).intent.data
|
||||
if (intentUri != null) {
|
||||
CoroutineScope(IO).launch {
|
||||
try {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val scheme: String = intentUri.scheme ?: String()
|
||||
if (scheme.startsWith("http")) {
|
||||
Timber.tag(TAG).i("App was started to handle a web link.")
|
||||
stationList.addAll(CollectionHelper.createStationsFromUrl(intentUri.toString()))
|
||||
}
|
||||
else if (scheme.startsWith("content")) {
|
||||
Timber.tag(TAG).i("App 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 {
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error handling view intent: ${e.message}")
|
||||
withContext(Main) {
|
||||
Toast.makeText(context, "Error loading station: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartPlayer() {
|
||||
try {
|
||||
val intent: Intent = (activity as Activity).intent
|
||||
if (intent.hasExtra(Keys.EXTRA_START_LAST_PLAYED_STATION)) {
|
||||
val station = CollectionHelper.getStation(collection, playerState.stationUuid)
|
||||
if (station != null) {
|
||||
controller?.play(activity as Context, station)
|
||||
} else {
|
||||
Timber.tag(TAG).e("Cannot start last played station: station not found")
|
||||
}
|
||||
} else if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) {
|
||||
val uuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String()
|
||||
val station = CollectionHelper.getStation(collection, uuid)
|
||||
if (station != null) {
|
||||
controller?.play(activity as Context, station)
|
||||
} else {
|
||||
Timber.tag(TAG).e("Cannot start station with UUID $uuid: station not found")
|
||||
}
|
||||
} else if (intent.hasExtra(Keys.EXTRA_STREAM_URI)) {
|
||||
val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String()
|
||||
if (streamUri.isNotBlank()) {
|
||||
controller?.playStreamDirectly(streamUri)
|
||||
} else {
|
||||
Timber.tag(TAG).e("Cannot start stream: URI is blank")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e("Error handling start player: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePeriodicSleepTimerUpdateRequest() {
|
||||
handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable)
|
||||
handler.postDelayed(periodicSleepTimerUpdateRequestRunnable, 0)
|
||||
}
|
||||
|
||||
private fun observeCollectionViewModel() {
|
||||
collectionViewModel.collectionLiveData.observe(this) { newCollection ->
|
||||
collection = newCollection
|
||||
val currentStation = CollectionHelper.getStation(newCollection, playerState.stationUuid)
|
||||
currentStation?.let {
|
||||
layout.updatePlayerViews(activity as Context, it, playerState.isPlaying)
|
||||
}
|
||||
handleStartIntent()
|
||||
handleNavigationArguments()
|
||||
}
|
||||
collectionViewModel.collectionSizeLiveData.observe(this) {
|
||||
layout.toggleOnboarding(activity as Context, collection.stations.size)
|
||||
updatePlayerViews()
|
||||
CollectionHelper.exportCollectionM3u(activity as Context, collection)
|
||||
CollectionHelper.exportCollectionPls(activity as Context, collection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigationArguments() {
|
||||
val updateCollection: Boolean =
|
||||
arguments?.getBoolean(Keys.ARG_UPDATE_COLLECTION, false) == true
|
||||
val updateStationImages: Boolean =
|
||||
arguments?.getBoolean(Keys.ARG_UPDATE_IMAGES, false) == true
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val periodicSleepTimerUpdateRequestRunnable: Runnable = object : Runnable {
|
||||
override fun run() {
|
||||
requestSleepTimerUpdate()
|
||||
handler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private var playerListener: Player.Listener = object : Player.Listener {
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
super.onMediaItemTransition(mediaItem, reason)
|
||||
mediaItem?.mediaId?.let { uuid ->
|
||||
playerState.stationUuid = uuid
|
||||
PreferencesHelper.saveCurrentStationId(uuid) // Сохраняем UUID сразу
|
||||
}
|
||||
updatePlayerViews()
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
playerState.isPlaying = isPlaying
|
||||
layout.animatePlaybackButtonStateTransition(activity as Context, isPlaying)
|
||||
|
||||
if (isPlaying) {
|
||||
layout.showPlayer(activity as Context)
|
||||
layout.showBufferingIndicator(buffering = false)
|
||||
} else {
|
||||
if (controller?.playWhenReady == true) {
|
||||
layout.showBufferingIndicator(buffering = true)
|
||||
} else {
|
||||
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)
|
||||
Timber.tag(TAG).e("Player error in fragment: ${error.errorCodeName} - ${error.message}")
|
||||
layout.togglePlayButton(false)
|
||||
layout.showBufferingIndicator(false)
|
||||
|
||||
val errorMessage = when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
|
||||
getString(R.string.toastmessage_connection_failed)
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
|
||||
"Stream unavailable (HTTP error)"
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
|
||||
"Invalid stream format"
|
||||
PlaybackException.ERROR_CODE_TIMEOUT ->
|
||||
"Connection timeout"
|
||||
else -> getString(R.string.toastmessage_connection_failed)
|
||||
}
|
||||
|
||||
Toast.makeText(activity, errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.google.gson.annotations.Expose
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class PlayerState(
|
||||
@Expose var stationUuid: String = String(),
|
||||
@Expose var isPlaying: Boolean = false,
|
||||
@Expose var sleepTimerRunning: Boolean = false
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.google.android.play.core.review.ReviewInfo
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import com.yaros.RadioUrl.helpers.GoogleServicesHelper
|
||||
import ru.rustore.sdk.review.RuStoreReviewManagerFactory
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
import androidx.core.content.edit
|
||||
|
||||
class ReviewManager(private val context: Context) {
|
||||
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("ReviewPrefs", Context.MODE_PRIVATE)
|
||||
private var firebaseAnalytics: Any? = null // Nullable для опциональной поддержки Firebase
|
||||
|
||||
init {
|
||||
// Инициализация Firebase Analytics только если доступен
|
||||
if (GoogleServicesHelper.isFirebaseAvailable()) {
|
||||
firebaseAnalytics = GoogleServicesHelper.getFirebaseAnalytics(context)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_GP_REVIEW_SHOWN = "gp_review_shown"
|
||||
private const val KEY_RS_REVIEW_SHOWN = "rs_review_shown"
|
||||
private const val KEY_INSTALL_TIME = "install_time"
|
||||
private const val KEY_GP_LAUNCH_COUNT = "gp_launch_count"
|
||||
private const val KEY_RS_LAUNCH_COUNT = "rs_launch_count"
|
||||
private const val LAUNCH_THRESHOLD = 10
|
||||
private const val RS_LAUNCH_DELAY_DAYS = 3L
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
initInstallTime()
|
||||
|
||||
when (getInstallSource()) {
|
||||
InstallSource.GOOGLE_PLAY -> handleGooglePlayLogic()
|
||||
InstallSource.RUSTORE -> handleRuStoreLogic()
|
||||
InstallSource.UNKNOWN -> handleUnknownSource()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initInstallTime() {
|
||||
if (!sharedPreferences.contains(KEY_INSTALL_TIME)) {
|
||||
sharedPreferences.edit() { putLong(KEY_INSTALL_TIME, System.currentTimeMillis()) }
|
||||
}
|
||||
}
|
||||
|
||||
// Улучшенное определение источника установки
|
||||
private fun getInstallSource(): InstallSource {
|
||||
return when {
|
||||
isInstalledViaGooglePlay() -> InstallSource.GOOGLE_PLAY
|
||||
isRuStoreAvailable() -> InstallSource.RUSTORE
|
||||
else -> InstallSource.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInstalledViaGooglePlay(): Boolean {
|
||||
return try {
|
||||
val installer = context.packageManager.getInstallerPackageName(context.packageName)
|
||||
installer == "com.android.vending"
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRuStoreAvailable(): Boolean {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo("ru.rustore.app", 0) != null
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGooglePlayLogic() {
|
||||
val launchCount = sharedPreferences.getInt(KEY_GP_LAUNCH_COUNT, 0) + 1
|
||||
sharedPreferences.edit() { putInt(KEY_GP_LAUNCH_COUNT, launchCount) }
|
||||
|
||||
if (launchCount >= LAUNCH_THRESHOLD &&
|
||||
!sharedPreferences.getBoolean(KEY_GP_REVIEW_SHOWN, false)) {
|
||||
showGooglePlayReview()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRuStoreLogic() {
|
||||
val launchCount = sharedPreferences.getInt(KEY_RS_LAUNCH_COUNT, 0) + 1
|
||||
sharedPreferences.edit() { putInt(KEY_RS_LAUNCH_COUNT, launchCount) }
|
||||
|
||||
val installTime = sharedPreferences.getLong(KEY_INSTALL_TIME, System.currentTimeMillis())
|
||||
val daysSinceInstall = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - installTime)
|
||||
|
||||
if (daysSinceInstall >= RS_LAUNCH_DELAY_DAYS &&
|
||||
!sharedPreferences.getBoolean(KEY_RS_REVIEW_SHOWN, false)) {
|
||||
showRuStoreReview()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnknownSource() {
|
||||
Timber.d("Unknown installation source, no review logic applied")
|
||||
}
|
||||
|
||||
private fun showGooglePlayReview() {
|
||||
Timber.d("Starting Google Play review flow")
|
||||
val manager = ReviewManagerFactory.create(context)
|
||||
|
||||
manager.requestReviewFlow().addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
manager.launchReviewFlow(context as android.app.Activity, task.result)
|
||||
.addOnCompleteListener {
|
||||
sharedPreferences.edit() { putBoolean(KEY_GP_REVIEW_SHOWN, true) }
|
||||
logReviewCompletedEvent("Google Play")
|
||||
}
|
||||
}
|
||||
}.addOnFailureListener {
|
||||
logReviewErrorEvent("Google Play", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRuStoreReview() {
|
||||
Timber.d("Starting RuStore review flow")
|
||||
val manager = RuStoreReviewManagerFactory.create(context)
|
||||
|
||||
manager.requestReviewFlow().addOnSuccessListener { reviewInfo ->
|
||||
manager.launchReviewFlow(reviewInfo).addOnSuccessListener {
|
||||
sharedPreferences.edit() { putBoolean(KEY_RS_REVIEW_SHOWN, true) }
|
||||
logReviewCompletedEvent("RuStore")
|
||||
}.addOnFailureListener {
|
||||
logReviewErrorEvent("RuStore", it)
|
||||
}
|
||||
}.addOnFailureListener {
|
||||
logReviewErrorEvent("RuStore", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logReviewCompletedEvent(source: String) {
|
||||
GoogleServicesHelper.logAnalyticsEvent(
|
||||
context,
|
||||
"review_event",
|
||||
mapOf(
|
||||
"source" to source,
|
||||
"type" to "review_completed"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun logReviewErrorEvent(source: String, exception: Throwable) {
|
||||
GoogleServicesHelper.logAnalyticsEvent(
|
||||
context,
|
||||
"review_error",
|
||||
mapOf(
|
||||
"source" to source,
|
||||
"error_type" to exception.javaClass.simpleName,
|
||||
"error_message" to exception.message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
enum class InstallSource {
|
||||
GOOGLE_PLAY, RUSTORE, UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
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.view.View
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.yaros.RadioUrl.BuildConfig
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.dialogs.ErrorDialog
|
||||
import com.yaros.RadioUrl.core.dialogs.YesNoDialog
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper.getColor
|
||||
import com.yaros.RadioUrl.helpers.BackupHelper
|
||||
import com.yaros.RadioUrl.helpers.FileHelper
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import com.yaros.RadioUrl.helpers.CacheManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener {
|
||||
|
||||
private val TAG: String = SettingsFragment::class.java.simpleName
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
(activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground)
|
||||
} else {
|
||||
val nightMode = AppCompatDelegate.getDefaultNightMode()
|
||||
if (nightMode == AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
(activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground)
|
||||
} else {
|
||||
(activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireContext(), android.R.color.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
||||
val context = preferenceManager.context
|
||||
val screen = preferenceManager.createPreferenceScreen(context)
|
||||
|
||||
// Инициализируем CacheManager
|
||||
CacheManager.init(context)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 "Cache Enable/Disable" preference
|
||||
val preferenceCacheEnabled = SwitchPreferenceCompat(activity as Context)
|
||||
preferenceCacheEnabled.title = getString(R.string.pref_cache_title)
|
||||
preferenceCacheEnabled.setIcon(R.drawable.ic_cache_24dp)
|
||||
preferenceCacheEnabled.key = Keys.PREF_CACHE_ENABLED
|
||||
preferenceCacheEnabled.summaryOn = getString(R.string.pref_cache_enable)
|
||||
preferenceCacheEnabled.summaryOff = getString(R.string.pref_cache_disable)
|
||||
preferenceCacheEnabled.isChecked = CacheManager.isCachingEnabled()
|
||||
preferenceCacheEnabled.setDefaultValue(true)
|
||||
preferenceCacheEnabled.setOnPreferenceChangeListener { _, newValue ->
|
||||
val isEnabled = newValue as Boolean
|
||||
if (isEnabled) {
|
||||
CacheManager.enableCaching()
|
||||
} else {
|
||||
CacheManager.disableCaching()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// set up "Cache Size" preference
|
||||
val preferenceCacheSize = ListPreference(activity as Context)
|
||||
preferenceCacheSize.title = getString(R.string.pref_cache_size_title)
|
||||
preferenceCacheSize.setIcon(R.drawable.ic_cache_24dp)
|
||||
preferenceCacheSize.key = Keys.PREF_CACHE_MAX_SIZE
|
||||
preferenceCacheSize.summary = getString(R.string.pref_cache_size_summary, CacheManager.getMaxCacheSize())
|
||||
preferenceCacheSize.entries = arrayOf(
|
||||
getString(R.string.pref_cache_size_small),
|
||||
getString(R.string.pref_cache_size_medium),
|
||||
getString(R.string.pref_cache_size_large),
|
||||
getString(R.string.pref_cache_size_xlarge)
|
||||
)
|
||||
preferenceCacheSize.entryValues = arrayOf(
|
||||
CacheManager.CACHE_SIZE_SMALL.toString(),
|
||||
CacheManager.CACHE_SIZE_MEDIUM.toString(),
|
||||
CacheManager.CACHE_SIZE_LARGE.toString(),
|
||||
CacheManager.CACHE_SIZE_XLARGE.toString()
|
||||
)
|
||||
preferenceCacheSize.setDefaultValue(CacheManager.CACHE_SIZE_MEDIUM.toString())
|
||||
preferenceCacheSize.setOnPreferenceChangeListener { preference, newValue ->
|
||||
val size = (newValue as String).toInt()
|
||||
CacheManager.setMaxCacheSize(size)
|
||||
preferenceCacheSize.summary = getString(R.string.pref_cache_size_summary, size)
|
||||
true
|
||||
}
|
||||
|
||||
// set up "Clear Cache" preference
|
||||
val preferenceClearCache = Preference(activity as Context)
|
||||
preferenceClearCache.title = getString(R.string.pref_cache_clear_title)
|
||||
preferenceClearCache.setIcon(R.drawable.ic_clear_24dp)
|
||||
val cacheSize = CacheManager.getCacheSizeMB()
|
||||
preferenceClearCache.summary = getString(R.string.pref_cache_clear_summary, String.format("%.2f", cacheSize))
|
||||
preferenceClearCache.setOnPreferenceClickListener {
|
||||
YesNoDialog(this).show(
|
||||
context = activity as Context,
|
||||
type = Keys.DIALOG_CLEAR_CACHE,
|
||||
message = R.string.dialog_clear_cache_message,
|
||||
yesButton = R.string.dialog_generic_button_okay
|
||||
)
|
||||
return@setOnPreferenceClickListener 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 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)
|
||||
preferenceCategoryAdvanced.contains(preferenceCacheEnabled)
|
||||
preferenceCategoryAdvanced.contains(preferenceCacheSize)
|
||||
preferenceCategoryAdvanced.contains(preferenceClearCache)
|
||||
|
||||
|
||||
// setup preference screen
|
||||
screen.addPreference(preferenceAppVersion)
|
||||
screen.addPreference(preferenceCategoryGeneral)
|
||||
screen.addPreference(preferenceThemeSelection)
|
||||
screen.addPreference(preferenceCategoryMaintenance)
|
||||
screen.addPreference(preferenceUpdateStationImages)
|
||||
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(preferenceCacheEnabled)
|
||||
screen.addPreference(preferenceCacheSize)
|
||||
screen.addPreference(preferenceClearCache)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Keys.DIALOG_CLEAR_CACHE -> {
|
||||
if (dialogResult) {
|
||||
// user tapped clear cache
|
||||
clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Clear cache */
|
||||
private fun clearCache() {
|
||||
CacheManager.clearCache()
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
R.string.toastmessage_cache_cleared,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
// Обновляем summary для кнопки очистки кеша
|
||||
val preferenceClearCache = findPreference<Preference>(getString(R.string.pref_cache_clear_title))
|
||||
preferenceClearCache?.summary = getString(R.string.pref_cache_clear_summary, "0.00")
|
||||
}
|
||||
|
||||
|
||||
/* 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 {
|
||||
Timber.tag(TAG).w("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 {
|
||||
Timber.tag(TAG).w("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)
|
||||
Timber.tag(TAG).e("Backing up to $targetUri")
|
||||
} else {
|
||||
Timber.tag(TAG).w("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.navigation_home, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates collection */
|
||||
private fun updateCollection() {
|
||||
if (NetworkHelper.isConnectedToNetwork()) {
|
||||
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.navigation_home, 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()) {
|
||||
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.navigation_home, 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) {
|
||||
Timber.tag(TAG).e("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) {
|
||||
Timber.tag(TAG).e("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) {
|
||||
Timber.tag(TAG).e("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) {
|
||||
Timber.tag(TAG).e("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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.helpers.AppThemeHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
|
||||
class SocialFragment : Fragment() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.activity_social, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val tgChannelClickble = view.findViewById<LinearLayout>(R.id.tgChannelClickble)
|
||||
val tgChannelImage = view.findViewById<ImageView>(R.id.tgChannelImage)
|
||||
val tgChannelTitle = view.findViewById<TextView>(R.id.tgChannelTitle)
|
||||
val tgChannelSummon = view.findViewById<TextView>(R.id.tgChannelSummon)
|
||||
|
||||
val tgBotClickble = view.findViewById<LinearLayout>(R.id.tgBotClickble)
|
||||
val tgBotImage = view.findViewById<ImageView>(R.id.tgBotImage)
|
||||
val tgBotTitle = view.findViewById<TextView>(R.id.tgBotTitle)
|
||||
val tgBotSummon = view.findViewById<TextView>(R.id.tgBotSummon)
|
||||
|
||||
val payClickable = view.findViewById<LinearLayout>(R.id.payClickable)
|
||||
val payImage = view.findViewById<ImageView>(R.id.payImage)
|
||||
val payTitle = view.findViewById<TextView>(R.id.payTitle)
|
||||
val paySummon = view.findViewById<TextView>(R.id.paySummon)
|
||||
|
||||
// Set images and titles
|
||||
tgChannelImage.setImageResource(R.drawable.ic_tg)
|
||||
tgChannelTitle.text = getString(R.string.pref_tg_title)
|
||||
tgChannelSummon.text = getString(R.string.pref_tg_summary)
|
||||
|
||||
tgBotImage.setImageResource(R.drawable.ic_tg)
|
||||
tgBotTitle.text = getString(R.string.pref_tg_title_bot)
|
||||
tgBotSummon.text = getString(R.string.pref_tg_summary_bot)
|
||||
|
||||
payImage.setImageResource(R.drawable.ic_coffee)
|
||||
payTitle.text = getString(R.string.pref_pay_title)
|
||||
paySummon.text = getString(R.string.pref_pay_summary)
|
||||
|
||||
tgChannelImage.contentDescription = getString(R.string.pref_tg_title)
|
||||
tgBotImage.contentDescription = getString(R.string.pref_tg_title_bot)
|
||||
payImage.contentDescription = getString(R.string.pref_pay_title)
|
||||
|
||||
tgChannelClickble.setOnClickListener {
|
||||
openLink("https://t.me/soundwaveradio")
|
||||
}
|
||||
|
||||
tgBotClickble.setOnClickListener {
|
||||
openLink("https://t.me/HelpRadioBot")
|
||||
}
|
||||
|
||||
payClickable.setOnClickListener {
|
||||
openLink("https://yoomoney.ru/to/410015169563471")
|
||||
}
|
||||
tgBotClickble.visibility = View.GONE
|
||||
|
||||
val locale = requireActivity().resources.configuration.locale
|
||||
if (locale.language == "ru") {
|
||||
payClickable.visibility = View.VISIBLE
|
||||
} else {
|
||||
payClickable.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLink(url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_THEME_SELECTION -> {
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yaros.RadioUrl.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.yaros.RadioUrl.MainActivity
|
||||
import com.yaros.RadioUrl.R
|
||||
|
||||
//@SuppressLint("CustomSplashScreen")
|
||||
class SplashScreen : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Установите splash screen перед базовой инициализацией
|
||||
// installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_splash)
|
||||
|
||||
// // Условие, при котором сплэшскрин будет оставаться видимым
|
||||
// splashScreen.setKeepOnScreenCondition {
|
||||
// // Например, можно использовать переменную для управления состоянием
|
||||
// // Здесь можно добавить логику, которая будет определять, когда скрыть сплэшскрин
|
||||
// // Например, возвращать true, если идет загрузка данных
|
||||
// }
|
||||
|
||||
// Имитация задержки для демонстрации сплэшскрина
|
||||
// Замените на вашу логику загрузки данных, если необходимо
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
// После завершения задержки переходите к основной активности
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
startActivity(intent)
|
||||
finish() // Закрываем текущую активность, чтобы она больше не отображалась
|
||||
}, 2000) // Задержка в 2 секунды (2000 миллисекунд)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.yaros.RadioUrl.ui.catalog
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.slider.Slider
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.adapters.RadioStationAdapter
|
||||
import com.yaros.RadioUrl.core.supabase.SupabaseApiRepository
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.core.collection.CollectionViewModel
|
||||
import com.yaros.RadioUrl.data.Station as ApiStation
|
||||
import com.yaros.RadioUrl.helpers.CollectionHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
class CatalogFragment : Fragment() {
|
||||
|
||||
private lateinit var stationsRecyclerView: RecyclerView
|
||||
private lateinit var stationAdapter: RadioStationAdapter
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var searchView: SearchView
|
||||
private var allStations: List<ApiStation> = emptyList()
|
||||
|
||||
private val apiRepository by lazy { SupabaseApiRepository(requireContext()) }
|
||||
private val collectionViewModel: CollectionViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_catalog, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
stationsRecyclerView = view.findViewById(R.id.stationsRecyclerView)
|
||||
progressBar = view.findViewById(R.id.progressBar)
|
||||
searchView = view.findViewById(R.id.searchView)
|
||||
|
||||
setupStationsRecyclerView()
|
||||
setupSearchView()
|
||||
loadStationsFromAPI()
|
||||
}
|
||||
|
||||
private fun setupSearchView() {
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterStations(newText ?: "")
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun filterStations(query: String) {
|
||||
val filteredList = if (query.isEmpty()) {
|
||||
allStations
|
||||
} else {
|
||||
allStations.filter { station ->
|
||||
station.name.contains(query, ignoreCase = true) ||
|
||||
station.country.contains(query, ignoreCase = true) ||
|
||||
station.language.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
stationAdapter.submitList(filteredList)
|
||||
}
|
||||
|
||||
private fun setupStationsRecyclerView() {
|
||||
stationAdapter = RadioStationAdapter(
|
||||
imageLoader = { imageView, url ->
|
||||
// TODO: Implement image loading with Glide or Picasso
|
||||
},
|
||||
onItemClick = { station ->
|
||||
// Handle item click if needed
|
||||
},
|
||||
onPlayClick = { station ->
|
||||
playStation(station)
|
||||
},
|
||||
onAddToCollectionClick = { station ->
|
||||
addStationToCollection(station)
|
||||
},
|
||||
onVolumeClick = { station ->
|
||||
showVolumeDialog(station)
|
||||
}
|
||||
)
|
||||
stationsRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = stationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun playStation(apiStation: ApiStation) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Start playback using PlayerService without adding to collection
|
||||
val playIntent = Intent(requireContext(), com.yaros.RadioUrl.PlayerService::class.java).apply {
|
||||
action = Keys.ACTION_PLAY_STREAM
|
||||
putExtra(Keys.EXTRA_STREAM_URI, apiStation.url)
|
||||
putExtra(Keys.EXTRA_STATION_NAME, apiStation.name)
|
||||
putExtra(Keys.EXTRA_STATION_IMAGE, apiStation.image ?: "")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
requireContext().startForegroundService(playIntent)
|
||||
} else {
|
||||
requireContext().startService(playIntent)
|
||||
}
|
||||
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Воспроизведение \"${apiStation.name}\"",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error playing station")
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка воспроизведения станции",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStationsFromAPI() {
|
||||
progressBar.visibility = View.VISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
when (val result = apiRepository.getRecentRadio()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
progressBar.visibility = View.GONE
|
||||
allStations = result.data
|
||||
stationAdapter.submitList(result.data)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
progressBar.visibility = View.GONE
|
||||
Timber.e("Error loading stations: ${result.message}")
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка загрузки станций: ${result.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addStationToCollection(apiStation: ApiStation) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Convert API Station to Core Station
|
||||
val coreStation = convertApiStationToCoreStation(apiStation)
|
||||
|
||||
// Get current collection
|
||||
val currentCollection = collectionViewModel.collectionLiveData.value ?: Collection()
|
||||
|
||||
// Add station to collection
|
||||
CollectionHelper.addStation(
|
||||
requireContext(),
|
||||
currentCollection,
|
||||
coreStation
|
||||
)
|
||||
|
||||
// Показываем сообщение в главном потоке
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Станция \"${apiStation.name}\" добавлена в коллекцию",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error adding station to collection")
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка добавления станции",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVolumeDialog(apiStation: ApiStation) {
|
||||
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_volume_control, null)
|
||||
val stationNameText = dialogView.findViewById<TextView>(R.id.stationNameText)
|
||||
val volumeSlider = dialogView.findViewById<Slider>(R.id.volumeSlider)
|
||||
val volumeValue = dialogView.findViewById<TextView>(R.id.volumeValue)
|
||||
val resetButton = dialogView.findViewById<MaterialButton>(R.id.resetButton)
|
||||
val closeButton = dialogView.findViewById<MaterialButton>(R.id.closeButton)
|
||||
|
||||
// Конвертируем API станцию в Core станцию для получения UUID
|
||||
val coreStation = convertApiStationToCoreStation(apiStation)
|
||||
val stationUuid = coreStation.uuid
|
||||
|
||||
stationNameText.text = apiStation.name
|
||||
|
||||
// Загружаем текущую громкость
|
||||
val currentVolume = com.yaros.RadioUrl.helpers.VolumeSettingsHelper.getVolumePercent(requireContext(), stationUuid)
|
||||
volumeSlider.value = currentVolume.toFloat()
|
||||
volumeValue.text = "$currentVolume%"
|
||||
|
||||
// Устанавливаем цвет в зависимости от громкости
|
||||
updateVolumeColor(volumeValue, currentVolume)
|
||||
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
|
||||
volumeSlider.addOnChangeListener { slider, value, fromUser ->
|
||||
if (fromUser) {
|
||||
val volumePercent = value.toInt()
|
||||
volumeValue.text = "$volumePercent%"
|
||||
com.yaros.RadioUrl.helpers.VolumeSettingsHelper.setVolumePercent(requireContext(), stationUuid, volumePercent)
|
||||
updateVolumeColor(volumeValue, volumePercent)
|
||||
}
|
||||
}
|
||||
|
||||
resetButton.setOnClickListener {
|
||||
volumeSlider.value = 100f
|
||||
volumeValue.text = "100%"
|
||||
com.yaros.RadioUrl.helpers.VolumeSettingsHelper.setVolumePercent(requireContext(), stationUuid, 100)
|
||||
updateVolumeColor(volumeValue, 100)
|
||||
}
|
||||
|
||||
closeButton.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun updateVolumeColor(volumeValue: TextView, volumePercent: Int) {
|
||||
when {
|
||||
volumePercent < 30 -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_orange_dark))
|
||||
volumePercent < 60 -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_orange_light))
|
||||
else -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_green_light))
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertApiStationToCoreStation(apiStation: ApiStation): Station {
|
||||
return Station(
|
||||
name = apiStation.name,
|
||||
streamUris = mutableListOf(apiStation.url),
|
||||
stream = 0,
|
||||
streamContent = "audio/mpeg", // Default content type
|
||||
image = apiStation.image ?: "",
|
||||
remoteImageLocation = apiStation.image ?: "",
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
country = apiStation.country,
|
||||
language = apiStation.language
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.yaros.RadioUrl.ui.categories
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.adapters.CategoryGridAdapter
|
||||
import com.yaros.RadioUrl.core.supabase.SupabaseApiRepository
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class CategoriesFragment : Fragment() {
|
||||
|
||||
private lateinit var categoriesRecyclerView: RecyclerView
|
||||
private lateinit var categoryAdapter: CategoryGridAdapter
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var searchView: SearchView
|
||||
private var allCategories: List<Category> = emptyList()
|
||||
|
||||
private val apiRepository by lazy { SupabaseApiRepository(requireContext()) }
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_categories, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
categoriesRecyclerView = view.findViewById(R.id.categoriesRecyclerView)
|
||||
progressBar = view.findViewById(R.id.progressBar)
|
||||
searchView = view.findViewById(R.id.searchView)
|
||||
|
||||
setupRecyclerView()
|
||||
setupSearchView()
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
categoryAdapter = CategoryGridAdapter { category ->
|
||||
navigateToCategoryStations(category)
|
||||
}
|
||||
categoriesRecyclerView.apply {
|
||||
layoutManager = GridLayoutManager(context, 2)
|
||||
adapter = categoryAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSearchView() {
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterCategories(newText ?: "")
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun filterCategories(query: String) {
|
||||
val filteredList = if (query.isEmpty()) {
|
||||
allCategories
|
||||
} else {
|
||||
allCategories.filter { category ->
|
||||
category.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
categoryAdapter.submitList(filteredList)
|
||||
}
|
||||
|
||||
private fun navigateToCategoryStations(category: Category) {
|
||||
// Навигация к списку станций категории
|
||||
val bundle = Bundle().apply {
|
||||
putInt("categoryId", category.id)
|
||||
putString("categoryName", category.name)
|
||||
}
|
||||
|
||||
// Если используется Navigation Component
|
||||
try {
|
||||
findNavController().navigate(
|
||||
//R.id.action_navigation_categories_to_categoryStationsFragment,
|
||||
bundle
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Если навигация не настроена, показываем Toast
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Открытие категории: ${category.name}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Timber.e(e, "Navigation error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCategories() {
|
||||
progressBar.visibility = View.VISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
when (val result = apiRepository.getCategories()) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
progressBar.visibility = View.GONE
|
||||
showCategories(result.data)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
progressBar.visibility = View.GONE
|
||||
showError(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCategories(categories: List<Category>) {
|
||||
allCategories = categories
|
||||
categoryAdapter.submitList(categories)
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
"Ошибка загрузки категорий: $message",
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
Timber.e("Error loading categories: $message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yaros.RadioUrl.ui.categories
|
||||
|
||||
class CategoriesViewModel(
|
||||
private val repository: CategoriesRepository
|
||||
) : ViewModel() {
|
||||
private val _categories = MutableLiveData<CategoriesState>()
|
||||
val categories: LiveData<CategoriesState> = _categories
|
||||
|
||||
fun loadCategories() {
|
||||
_categories.value = CategoriesState.Loading
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.getCategories()
|
||||
if (response.status == "ok") {
|
||||
_categories.value = CategoriesState.Success(response.categories)
|
||||
} else {
|
||||
_categories.value = CategoriesState.Error("Server error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_categories.value = CategoriesState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CategoriesState {
|
||||
object Loading : CategoriesState()
|
||||
data class Success(val data: List<Category>) : CategoriesState()
|
||||
data class Error(val message: String) : CategoriesState()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yaros.RadioUrl.ui.categories
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.yaros.RadioUrl.BuildConfig
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.APIInterface.data.Category
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun bind(category: Category) {
|
||||
with(itemView) {
|
||||
categoryName.text = category.name
|
||||
stationCount.text = category.stationsCount.toString()
|
||||
|
||||
Glide.with(context)
|
||||
.load("${BuildConfig.BASE_URL}images/${category.image}")
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.apply(RequestOptions()
|
||||
.placeholder(R.drawable.ic_category_placeholder)
|
||||
.error(R.drawable.ic_category_placeholder))
|
||||
.into(categoryImage)
|
||||
|
||||
setOnClickListener { onItemClick(category) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Category>() {
|
||||
override fun areItemsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Category, newItem: Category): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.yaros.RadioUrl.ui.categories
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.slider.Slider
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.adapters.RadioStationAdapter
|
||||
import com.yaros.RadioUrl.core.supabase.SupabaseApiRepository
|
||||
import com.yaros.RadioUrl.core.Collection
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.core.collection.CollectionViewModel
|
||||
import com.yaros.RadioUrl.data.Station as ApiStation
|
||||
import com.yaros.RadioUrl.helpers.CollectionHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
class CategoryStationsFragment : Fragment() {
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var stationsRecyclerView: RecyclerView
|
||||
private lateinit var stationAdapter: RadioStationAdapter
|
||||
private lateinit var progressBar: ProgressBar
|
||||
|
||||
private val apiRepository by lazy { SupabaseApiRepository(requireContext()) }
|
||||
private val collectionViewModel: CollectionViewModel by activityViewModels()
|
||||
|
||||
private var categoryId: Int = 0
|
||||
private var categoryName: String = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
arguments?.let {
|
||||
categoryId = it.getInt("categoryId", 0)
|
||||
categoryName = it.getString("categoryName", "")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_category_stations, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
stationsRecyclerView = view.findViewById(R.id.stationsRecyclerView)
|
||||
progressBar = view.findViewById(R.id.progressBar)
|
||||
|
||||
toolbar.title = categoryName
|
||||
toolbar.setNavigationOnClickListener {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
|
||||
setupStationsRecyclerView()
|
||||
loadStationsForCategory(categoryId)
|
||||
}
|
||||
|
||||
private fun setupStationsRecyclerView() {
|
||||
stationAdapter = RadioStationAdapter(
|
||||
imageLoader = { imageView, url ->
|
||||
// TODO: Implement image loading with Glide or Picasso
|
||||
},
|
||||
onItemClick = { station ->
|
||||
// Handle item click if needed
|
||||
},
|
||||
onPlayClick = { station ->
|
||||
playStation(station)
|
||||
},
|
||||
onAddToCollectionClick = { station ->
|
||||
addStationToCollection(station)
|
||||
},
|
||||
onVolumeClick = { station ->
|
||||
showVolumeDialog(station)
|
||||
}
|
||||
)
|
||||
stationsRecyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = stationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun playStation(apiStation: ApiStation) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Start playback using PlayerService without adding to collection
|
||||
val playIntent = Intent(requireContext(), com.yaros.RadioUrl.PlayerService::class.java).apply {
|
||||
action = Keys.ACTION_PLAY_STREAM
|
||||
putExtra(Keys.EXTRA_STREAM_URI, apiStation.url)
|
||||
putExtra(Keys.EXTRA_STATION_NAME, apiStation.name)
|
||||
putExtra(Keys.EXTRA_STATION_IMAGE, apiStation.image ?: "")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
requireContext().startForegroundService(playIntent)
|
||||
} else {
|
||||
requireContext().startService(playIntent)
|
||||
}
|
||||
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Воспроизведение \"${apiStation.name}\"",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error playing station")
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка воспроизведения станции",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStationsForCategory(categoryId: Int) {
|
||||
progressBar.visibility = View.VISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
when (val result = apiRepository.getCategoryDetail(categoryId)) {
|
||||
is SupabaseApiRepository.NetworkResult.Success -> {
|
||||
progressBar.visibility = View.GONE
|
||||
stationAdapter.submitList(result.data)
|
||||
}
|
||||
is SupabaseApiRepository.NetworkResult.Error -> {
|
||||
progressBar.visibility = View.GONE
|
||||
Timber.e("Error loading stations: ${result.message}")
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка загрузки станций: ${result.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addStationToCollection(apiStation: ApiStation) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Convert API Station to Core Station
|
||||
val coreStation = convertApiStationToCoreStation(apiStation)
|
||||
|
||||
// Get current collection
|
||||
val currentCollection = collectionViewModel.collectionLiveData.value ?: Collection()
|
||||
|
||||
// Add station to collection
|
||||
CollectionHelper.addStation(
|
||||
requireContext(),
|
||||
currentCollection,
|
||||
coreStation
|
||||
)
|
||||
|
||||
// Показываем сообщение в главном потоке
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Станция \"${apiStation.name}\" добавлена в коллекцию",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error adding station to collection")
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Ошибка добавления станции",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVolumeDialog(apiStation: ApiStation) {
|
||||
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_volume_control, null)
|
||||
val stationNameText = dialogView.findViewById<TextView>(R.id.stationNameText)
|
||||
val volumeSlider = dialogView.findViewById<Slider>(R.id.volumeSlider)
|
||||
val volumeValue = dialogView.findViewById<TextView>(R.id.volumeValue)
|
||||
val resetButton = dialogView.findViewById<MaterialButton>(R.id.resetButton)
|
||||
val closeButton = dialogView.findViewById<MaterialButton>(R.id.closeButton)
|
||||
|
||||
// Конвертируем API станцию в Core станцию для получения UUID
|
||||
val coreStation = convertApiStationToCoreStation(apiStation)
|
||||
val stationUuid = coreStation.uuid
|
||||
|
||||
stationNameText.text = apiStation.name
|
||||
|
||||
// Загружаем текущую громкость
|
||||
val currentVolume = com.yaros.RadioUrl.helpers.VolumeSettingsHelper.getVolumePercent(requireContext(), stationUuid)
|
||||
volumeSlider.value = currentVolume.toFloat()
|
||||
volumeValue.text = "$currentVolume%"
|
||||
|
||||
// Устанавливаем цвет в зависимости от громкости
|
||||
updateVolumeColor(volumeValue, currentVolume)
|
||||
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
|
||||
volumeSlider.addOnChangeListener { slider, value, fromUser ->
|
||||
if (fromUser) {
|
||||
val volumePercent = value.toInt()
|
||||
volumeValue.text = "$volumePercent%"
|
||||
com.yaros.RadioUrl.helpers.VolumeSettingsHelper.setVolumePercent(requireContext(), stationUuid, volumePercent)
|
||||
updateVolumeColor(volumeValue, volumePercent)
|
||||
}
|
||||
}
|
||||
|
||||
resetButton.setOnClickListener {
|
||||
volumeSlider.value = 100f
|
||||
volumeValue.text = "100%"
|
||||
com.yaros.RadioUrl.helpers.VolumeSettingsHelper.setVolumePercent(requireContext(), stationUuid, 100)
|
||||
updateVolumeColor(volumeValue, 100)
|
||||
}
|
||||
|
||||
closeButton.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun updateVolumeColor(volumeValue: TextView, volumePercent: Int) {
|
||||
when {
|
||||
volumePercent < 30 -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_orange_dark))
|
||||
volumePercent < 60 -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_orange_light))
|
||||
else -> volumeValue.setTextColor(requireContext().getColor(android.R.color.holo_green_light))
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertApiStationToCoreStation(apiStation: ApiStation): Station {
|
||||
return Station(
|
||||
name = apiStation.name,
|
||||
streamUris = mutableListOf(apiStation.url),
|
||||
stream = 0,
|
||||
streamContent = "audio/mpeg", // Default content type
|
||||
image = apiStation.image ?: "",
|
||||
remoteImageLocation = apiStation.image ?: "",
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
country = apiStation.country,
|
||||
language = apiStation.language
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yaros.RadioUrl.ui.search
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.URLUtil
|
||||
import android.widget.Toast
|
||||
import com.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import com.yaros.RadioUrl.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?
|
||||
)
|
||||
class DirectInputCheck(private var directInputCheckListener: DirectInputCheckListener) {
|
||||
|
||||
interface DirectInputCheckListener {
|
||||
fun onDirectInputCheck(stationList: MutableList<Station>) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var lastCheckedAddress: String = String()
|
||||
|
||||
|
||||
fun checkStationAddress(context: Context, query: String) {
|
||||
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()) {
|
||||
directInputCheckListener.onDirectInputCheck(stationList)
|
||||
} else {
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun extractIcecastMetadata(streamUri: String): IcecastMetadata {
|
||||
return withContext(IO) {
|
||||
try {
|
||||
val client = com.yaros.RadioUrl.helpers.NetworkHelper.createSecureOkHttpClient()
|
||||
val request = Request.Builder()
|
||||
.url(streamUri)
|
||||
.addHeader("Icy-MetaData", "1")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
response.use {
|
||||
val icecastHeaders = response.headers
|
||||
val title = icecastHeaders["icy-name"]
|
||||
IcecastMetadata(title?.takeIf { it.isNotEmpty() } ?: streamUri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
timber.log.Timber.e("Error extracting Icecast metadata: ${e.message}")
|
||||
IcecastMetadata(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)
|
||||
if (lastCheckedAddress != streamUri) {
|
||||
stationList.add(station)
|
||||
}
|
||||
lastCheckedAddress = streamUri
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yaros.RadioUrl.ui.search
|
||||
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
import java.util.*
|
||||
|
||||
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,
|
||||
@Expose val countrycode: String,
|
||||
@Expose val language: String,
|
||||
@Expose val languagecodes: String,
|
||||
@Expose val country: String
|
||||
) {
|
||||
|
||||
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,
|
||||
countrycode = countrycode,
|
||||
language = language,
|
||||
languagecodes = languagecodes,
|
||||
country = country
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.yaros.RadioUrl.ui.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.yaros.RadioUrl.BuildConfig
|
||||
import com.yaros.RadioUrl.Keys
|
||||
import com.yaros.RadioUrl.helpers.NetworkHelper
|
||||
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class RadioBrowserSearch(private var radioBrowserSearchListener: RadioBrowserSearchListener) {
|
||||
|
||||
private val TAG: String = RadioBrowserSearch::class.java.simpleName
|
||||
|
||||
interface RadioBrowserSearchListener {
|
||||
fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>)
|
||||
}
|
||||
|
||||
private lateinit var radioBrowserApi: String
|
||||
private lateinit var requestQueue: RequestQueue
|
||||
|
||||
init {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
radioBrowserApi = PreferencesHelper.loadRadioBrowserApiAddress()
|
||||
updateRadioBrowserApi()
|
||||
}
|
||||
}
|
||||
|
||||
fun searchStation(context: Context, query: String, searchType: Int) {
|
||||
Timber.tag(TAG).v("Search - Querying $radioBrowserApi for: $query")
|
||||
|
||||
requestQueue = Volley.newRequestQueue(context)
|
||||
val requestUrl: String = when (searchType) {
|
||||
Keys.SEARCH_TYPE_BY_UUID -> "https://soundwaveradio.ru/admin_panel/api/api.php?x=getSearchResults&api_key=cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE&search=${query}"
|
||||
else -> "https://soundwaveradio.ru/admin_panel/api/api.php?x=getSearchResults&api_key=cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE&search=${query.replace(" ", "+")}"
|
||||
}
|
||||
Timber.v(requestUrl)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
stringRequest.retryPolicy = object : RetryPolicy {
|
||||
override fun getCurrentTimeout(): Int {
|
||||
return 30000
|
||||
}
|
||||
|
||||
override fun getCurrentRetryCount(): Int {
|
||||
return 30000
|
||||
}
|
||||
|
||||
@Throws(VolleyError::class)
|
||||
override fun retry(error: VolleyError) {
|
||||
Timber.tag(TAG).w("Error: $error")
|
||||
}
|
||||
}
|
||||
|
||||
requestQueue.add(stringRequest)
|
||||
}
|
||||
|
||||
fun stopSearchRequest() {
|
||||
if (this::requestQueue.isInitialized) {
|
||||
requestQueue.stop()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun updateRadioBrowserApi() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val deferred: Deferred<String> = async { NetworkHelper.getRadioBrowserServerSuspended() }
|
||||
radioBrowserApi = deferred.await()
|
||||
}
|
||||
}
|
||||
|
||||
private val responseListener: Response.Listener<JSONArray> = Response.Listener<JSONArray> { response ->
|
||||
if (response != null) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val results = createRadioBrowserResult(response.toString())
|
||||
withContext(Dispatchers.Main) {
|
||||
radioBrowserSearchListener.onRadioBrowserSearchResults(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val errorListener: Response.ErrorListener = Response.ErrorListener { error ->
|
||||
Timber.tag(TAG).w("Error: $error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.yaros.RadioUrl.ui.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.yaros.RadioUrl.R
|
||||
import com.yaros.RadioUrl.core.Station
|
||||
|
||||
class SearchResultAdapter(
|
||||
private val listener: SearchResultAdapterListener,
|
||||
var searchResults: List<Station>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var selectedPosition: Int = RecyclerView.NO_POSITION
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var paused: Boolean = false
|
||||
private var isItemSelected: Boolean = false
|
||||
|
||||
interface SearchResultAdapterListener {
|
||||
fun onSearchResultTapped(result: Station)
|
||||
fun activateAddButton()
|
||||
fun deactivateAddButton()
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return searchResults.size
|
||||
}
|
||||
|
||||
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val searchResultViewHolder: SearchResultViewHolder = holder as SearchResultViewHolder
|
||||
val searchResult: Station = searchResults[position]
|
||||
|
||||
searchResultViewHolder.nameView.text = searchResult.name
|
||||
searchResultViewHolder.streamView.text = searchResult.getStreamUri()
|
||||
|
||||
if (searchResult.codec.isNotEmpty()) {
|
||||
if (searchResult.bitrate == 0) {
|
||||
searchResultViewHolder.bitrateView.text = searchResult.codec
|
||||
} else {
|
||||
searchResultViewHolder.bitrateView.text = buildString {
|
||||
append(searchResult.codec)
|
||||
append(" | ")
|
||||
append(searchResult.bitrate)
|
||||
append("kbps")
|
||||
append(" | ")
|
||||
append(searchResult.country)
|
||||
append(" | ")
|
||||
append(searchResult.language)}
|
||||
}
|
||||
} else {
|
||||
searchResultViewHolder.bitrateView.visibility = View.GONE
|
||||
}
|
||||
|
||||
val isSelected = selectedPosition == holder.adapterPosition
|
||||
searchResultViewHolder.searchResultLayout.isSelected = isSelected
|
||||
|
||||
searchResultViewHolder.nameView.isSelected = isSelected
|
||||
searchResultViewHolder.streamView.isSelected = isSelected
|
||||
|
||||
searchResultViewHolder.nameView.setFadingEdgeLength(10)
|
||||
searchResultViewHolder.streamView.setFadingEdgeLength(10)
|
||||
|
||||
searchResultViewHolder.searchResultLayout.setOnClickListener {
|
||||
val previousSelectedPosition = selectedPosition
|
||||
selectedPosition = holder.adapterPosition
|
||||
notifyItemChanged(previousSelectedPosition)
|
||||
notifyItemChanged(selectedPosition)
|
||||
|
||||
val samePositionSelected = previousSelectedPosition == selectedPosition
|
||||
|
||||
if (samePositionSelected) {
|
||||
resetSelection(false)
|
||||
} else {
|
||||
val selectedStation = searchResults[holder.adapterPosition]
|
||||
performPrePlayback(searchResultViewHolder.searchResultLayout.context, selectedStation.getStreamUri())
|
||||
listener.onSearchResultTapped(searchResult)
|
||||
}
|
||||
|
||||
isItemSelected = !samePositionSelected
|
||||
|
||||
if (isItemSelected) {
|
||||
listener.activateAddButton()
|
||||
} else {
|
||||
listener.deactivateAddButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun performPrePlayback(context: Context, streamUri: String) {
|
||||
if (streamUri.contains(".m3u8")) {
|
||||
stopPrePlayback()
|
||||
|
||||
Toast.makeText(context, R.string.toastmessage_preview_playback_failed, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
stopRadioPlayback(context)
|
||||
|
||||
stopPrePlayback()
|
||||
|
||||
exoPlayer = ExoPlayer.Builder(context).build()
|
||||
|
||||
val mediaItem = MediaItem.fromUri(streamUri)
|
||||
|
||||
exoPlayer?.setMediaItem(mediaItem)
|
||||
|
||||
exoPlayer?.prepare()
|
||||
exoPlayer?.play()
|
||||
|
||||
Toast.makeText(context, R.string.toastmessage_preview_playback_started, Toast.LENGTH_SHORT).show()
|
||||
|
||||
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() {
|
||||
exoPlayer?.stop()
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
|
||||
|
||||
private fun stopRadioPlayback(context: Context) {
|
||||
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")
|
||||
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#80000000"
|
||||
android:startColor="#00000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="#00000000" android:pathData="M5.32,1.5L18.68,1.5A1.91,1.91 0,0 1,20.59 3.41L20.59,20.59A1.91,1.91 0,0 1,18.68 22.5L5.32,22.5A1.91,1.91 0,0 1,3.41 20.59L3.41,3.41A1.91,1.91 0,0 1,5.32 1.5z" android:strokeColor="@color/icon_default" android:strokeWidth="1.91"/>
|
||||
|
||||
<path android:fillColor="#00000000" android:pathData="M12,10.09m-4.77,0a4.77,4.77 0,1 1,9.54 0a4.77,4.77 0,1 1,-9.54 0" android:strokeColor="@color/icon_default" android:strokeWidth="1.91"/>
|
||||
|
||||
<path android:fillColor="#00000000" android:pathData="M13.43,11.52L16.3,14.39" android:strokeColor="@color/icon_default" android:strokeWidth="1.91"/>
|
||||
|
||||
<path android:fillColor="@color/icon_default" android:pathData="M12,10.09m-0.95,0a0.95,0.95 0,1 1,1.9 0a0.95,0.95 0,1 1,-1.9 0"/>
|
||||
|
||||
<path android:fillColor="#00000000" android:pathData="M7.23,18.68L16.77,18.68" android:strokeColor="@color/icon_default" android:strokeWidth="1.91"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<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="?attr/colorControlNormal"
|
||||
android:pathData="M12,2l-5.5,9h11z"/>
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M17.5,17.5m-4.5,0a4.5,4.5 0,1 1,9 0a4.5,4.5 0,1 1,-9 0"/>
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M3,13.5h8v8H3z"/>
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="@android:color/white"
|
||||
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z" />
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user