This commit is contained in:
2026-02-05 13:47:39 +03:00
commit d4d78ba52b
295 changed files with 24155 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+188
View File
@@ -0,0 +1,188 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.devtools.ksp' version '2.1.0-1.0.29'
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
id 'com.google.dagger.hilt.android' version '2.41' apply false
id 'com.google.gms.google-services'
id 'kotlin-parcelize'
id 'com.google.firebase.crashlytics'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.10'
}
android {
compileSdk 35
namespace 'com.yaros.RadioUrl'
defaultConfig {
applicationId "com.yaros.RadioUrl"
minSdk 23
targetSdk 35
versionCode 29
versionName "1.9.9"
resourceConfigurations += ['en', 'ru', 'uk']
setProperty('archivesBaseName', 'RadioUrl' + versionName)
multiDexEnabled true
}
kotlinOptions {
jvmTarget = '17'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildTypes {
debug {
minifyEnabled false
shrinkResources false
crunchPngs false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
applicationIdSuffix = ".debug"
buildConfigField "String", "BASE_URL", '"https://soundwaveradio.ru/admin_panel/api/api.php"'
buildConfigField "String", "API_KEY", '"cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"'
}
release {
minifyEnabled true
shrinkResources true
crunchPngs true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
manifestPlaceholders = [crashlyticsCollectionEnabled: "false"]
buildConfigField "String", "BASE_URL", '"https://soundwaveradio.ru/admin_panel/api/api.php"'
buildConfigField "String", "API_KEY", '"cda11lHY0ZafN2nrti4U5QAKMDhTw7Czm1xoSsyVLduvRegkqE"'
}
}
bundle {
storeArchive {
enable = true
}
}
buildFeatures {
buildConfig true
compose false
viewBinding true
}
composeOptions {
kotlinCompilerExtensionVersion "1.5.11"
}
lint {
disable 'MobileAdsSdkOutdatedVersion'
}
}
dependencies {
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.10"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1"
// Compose
implementation "androidx.compose.ui:ui:1.7.8"
implementation "androidx.compose.material3:material3:1.3.2"
implementation "androidx.activity:activity-compose:1.10.1"
implementation "androidx.compose.ui:ui-tooling-preview:1.7.8"
implementation "io.coil-kt.coil3:coil-compose:3.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7"
// AndroidX Libraries
implementation "androidx.core:core-ktx:1.16.0"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.10.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.7"
implementation "androidx.lifecycle:lifecycle-process:2.8.7"
implementation "androidx.navigation:navigation-fragment-ktx:2.8.9"
implementation "androidx.navigation:navigation-ui-ktx:2.8.9"
implementation "androidx.palette:palette-ktx:1.0.0"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.viewpager2:viewpager2:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.4.0"
implementation "androidx.media3:media3-ui:1.6.1"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
// Media3 Exoplayer
implementation "androidx.media3:media3-exoplayer:1.6.1"
implementation "androidx.media3:media3-exoplayer-dash:1.6.1"
implementation "androidx.media3:media3-exoplayer-hls:1.6.1"
implementation "androidx.media3:media3-exoplayer-smoothstreaming:1.6.1"
implementation "androidx.media3:media3-datasource-cronet:1.6.1"
implementation "androidx.media3:media3-datasource-okhttp:1.6.1"
implementation "androidx.media3:media3-datasource-rtmp:1.6.1"
implementation "androidx.media3:media3-session:1.6.1"
implementation "androidx.media3:media3-decoder:1.6.1"
implementation "androidx.media3:media3-datasource:1.6.1"
implementation "androidx.media3:media3-common:1.6.1"
// Room
implementation "androidx.room:room-common:2.7.0"
implementation "androidx.room:room-runtime:2.7.0"
implementation "androidx.room:room-ktx:2.7.0"
ksp "androidx.room:room-compiler:2.7.0"
annotationProcessor 'androidx.room:room-compiler:2.7.0'
// Firebase (включены в APK, но используются только при наличии GMS на устройстве)
implementation platform("com.google.firebase:firebase-bom:33.12.0")
implementation "com.google.firebase:firebase-analytics"
implementation "com.google.firebase:firebase-crashlytics"
implementation "com.google.firebase:firebase-config"
implementation "com.google.firebase:firebase-messaging"
implementation "com.google.firebase:firebase-perf"
// Google Play Services (для runtime-детекции)
implementation "com.google.android.gms:play-services-base:18.5.0"
// Other Libraries
implementation "com.jakewharton.timber:timber:5.0.1"
implementation "com.google.android.material:material:1.12.0"
implementation "com.google.code.gson:gson:2.12.0"
implementation "com.android.volley:volley:1.2.1"
implementation "com.google.android.gms:play-services-ads-identifier:18.2.0"
implementation 'com.yandex.android:mobileads:7.11.0'
implementation "com.yandex.ads.mediation:mobileads-unityads:4.12.5.3"
implementation "com.yandex.ads.mediation:mobileads-mytarget:5.22.1.7"
implementation 'com.yandex.ads.mediation:mobileads-startapp:5.0.2.6'
implementation 'com.yandex.ads.mediation:mobileads-mintegral:16.8.61.3'
implementation 'com.yandex.ads.mediation:mobileads-vungle:7.4.2.1'
implementation 'com.yandex.ads.mediation:mobileads-chartboost:9.3.1.14'
implementation 'com.sothree.slidinguppanel:library:3.4.0'
implementation 'com.h6ah4i.android.widget.verticalseekbar:verticalseekbar:1.0.0'
implementation 'com.google.android.play:core-ktx:1.8.1'
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
implementation 'com.github.claucookie.miniequalizer:library:1.0.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation(platform('com.squareup.okhttp3:okhttp-bom:4.12.0'))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
implementation 'com.facebook.shimmer:shimmer:0.5.0'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'commons-io:commons-io:2.18.0'
implementation(platform("ru.rustore.sdk:bom:7.0.0"))
implementation("ru.rustore.sdk:review")
// Dagger Hilt
implementation "com.google.dagger:hilt-android:2.55"
ksp "com.google.dagger:hilt-compiler:2.55"
// Work
implementation "androidx.work:work-runtime-ktx:2.10.0"
// Splash Screen
implementation "androidx.core:core-splashscreen:1.0.1"
// Supabase
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'
}
+48
View File
@@ -0,0 +1,48 @@
{
"project_info": {
"project_number": "815835836893",
"project_id": "soundwave-9c30c",
"storage_bucket": "soundwave-9c30c.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:815835836893:android:982529e4339fcc4cae0634",
"android_client_info": {
"package_name": "com.yaros.RadioUrl"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyA8LxXonrqBx8JgSP9o35bw87Zlcp4k2uc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:815835836893:android:b0b75b27f68e4bf9ae0634",
"android_client_info": {
"package_name": "com.yaros.RadioUrl.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyA8LxXonrqBx8JgSP9o35bw87Zlcp4k2uc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+36
View File
@@ -0,0 +1,36 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable, Signature, *Annotation*
-dontwarn com.yandex.metrica.ModulesFacade
# If you keep the line number information, uncomment this to
# hide the original source file name.
-renamesourcefileattribute SourceFile
# Preserve the core classes - because they need to be de-/serialized with GSON
-keep public class com.yaros.RadioUrl.core.** { *; }
-keep public class com.yaros.RadioUrl.PlayerService { *; }
-keep public class com.yaros.RadioUrl.ui.search.RadioBrowserResult { *; }
-keep class com.yaros.RadioUrl.ui.FavoriteSong.** { *; }
-keep class com.google.firebase.** { *; }
-keep class com.yaros.RadioUrl.model.** { *; }
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class com.google.gson.stream.** { *; }
-keep class com.yaros.RadioUrl.model.** { *; }
-dontwarn com.yandex.metrica.IIdentifierCallback
-dontwarn com.yandex.metrica.YandexMetrica
-dontwarn com.yandex.metrica.p
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.yaros.RadioUrl",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 29,
"versionName": "1.9.9",
"outputFile": "RadioUrl1.9.9-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/RadioUrl1.9.9-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/RadioUrl1.9.9-release.dm"
]
}
],
"minSdkVersionForDexing": 23
}
+124
View File
@@ -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>

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