mirror of
https://github.com/Michatec/Radio.git
synced 2026-01-30 23:17:21 +00:00
Initial commit
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*.iml
|
||||
.gradle
|
||||
.DS_Store
|
||||
/local.properties
|
||||
/.idea
|
||||
/build
|
||||
/captures
|
||||
23
LICENSE.md
Normal file
23
LICENSE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright (c) 2025 - Michatec
|
||||
--------------------------------
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<div align="center">
|
||||
|
||||
### ℹ️ About Radio
|
||||
**Radio is an application with a minimalist approach to listening to radio over the Internet.** <br>
|
||||
**Radio only offers a very basic search option, and it imports audio streaming links when you tap them in a web browser.** <br>
|
||||
**Pull request are welcome at any time.**<br>
|
||||
|
||||
**Radio is free software. It is released under the [MIT open source license](https://opensource.org/licenses/MIT).**
|
||||
</div>
|
||||
|
||||
----------------------------------------
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Install Radio</summary>
|
||||
<br>
|
||||
<a href="https://github.com/michatec/Radio/releases/latest"><img src="https://user-images.githubusercontent.com/15986930/229208526-e5a63be5-0d0b-48ab-a222-9f2f2faf0ee4.png" height="80px"></a>
|
||||
</details>
|
||||
|
||||
----------------------------------------
|
||||
|
||||
<details>
|
||||
<summary>💡 Frequent Questions</summary>
|
||||
|
||||
Q: How can I add a radio station
|
||||
A: There are three ways to add a radio station to Radio: Use Search, add playlist file address (M3U, PLS), enter a raw stream address. The last way will not support the update feature.
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
|
||||
Q: How does the update feature work?
|
||||
A: The update feature will try to fetch the current stream address of a station as well as the updated name and station image. The feature will not work for stations added via a raw stream address, or for stations imported from Radio v3.
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
|
||||
Q: Where do the radio station search results come from?
|
||||
A: Radio searches the [radio-browser.info](http://www.radio-browser.info/) online database.
|
||||
You can help out the radio-browser.info community by [adding the missing station](http://www.radio-browser.info/gui/#!/add) to their database.
|
||||
</details>
|
||||
|
||||
----------------------------------------
|
||||
|
||||
<details>
|
||||
<summary>🔊 Supported formats</summary>
|
||||
<br>
|
||||
|
||||
| Supported formats | 🔊 |
|
||||
| ------------------ | -- |
|
||||
| AAC | ✅ |
|
||||
| AAC+ | ✅ |
|
||||
| FLAC | ✅ |
|
||||
| HLS (M3U8) | ✅ |
|
||||
| M3U | ✅ |
|
||||
| MP3 | ✅ |
|
||||
| OGG (Vorbis) | ✅ |
|
||||
| OPUS | ✅ |
|
||||
| PLS | ✅ |
|
||||
</details>
|
||||
|
||||
----------------------------------------
|
||||
|
||||
<details>
|
||||
<summary>📜️ Credit</summary>
|
||||
|
||||
Base app Michatec.
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
<table><td>
|
||||
<a href="#start-of-content">↥ Scroll to top</a>
|
||||
</td></table>
|
||||
</div>
|
||||
3
app/.gitignore
vendored
Normal file
3
app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/build
|
||||
/release
|
||||
/debug
|
||||
68
app/build.gradle
Normal file
68
app/build.gradle
Normal file
@@ -0,0 +1,68 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
android {
|
||||
namespace 'com.michatec.radio'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'com.michatec.radio'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 128
|
||||
versionName '12.8'
|
||||
resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk']
|
||||
setProperty('archivesBaseName', 'Radio_' + versionName)
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
crunchPngs false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
crunchPngs true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
// Google Stuff //
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
||||
// AndroidX Stuff //
|
||||
implementation 'androidx.activity:activity-ktx:1.8.1'
|
||||
implementation 'androidx.palette:palette-ktx:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.0'
|
||||
implementation 'androidx.media3:media3-session:1.2.0'
|
||||
implementation 'androidx.media3:media3-datasource-okhttp:1.2.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||
|
||||
// Volley HTTP request //
|
||||
implementation 'com.android.volley:volley:1.2.1'
|
||||
}
|
||||
25
app/proguard-rules.pro
vendored
Normal file
25
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# Preserve the core classes - because they need to be de-/serialized with GSON
|
||||
-keep public class com.michatec.radio.core.** { *; }
|
||||
-keep public class com.michatec.radio.search.RadioBrowserResult { *; }
|
||||
170
app/src/main/AndroidManifest.xml
Normal file
170
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".Radio"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="tiramisu">
|
||||
|
||||
<!-- ANDROID AUTO SUPPORT -->
|
||||
<!-- https://developer.android.com/training/auto/audio/ -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<!-- Main activity for radio station playback on phone -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:exported="true">
|
||||
|
||||
<!-- react to main intents -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to be recognized as a music player -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MUSIC_PLAYER" />
|
||||
<category android:name="android.intent.category.CATEGORY_APP_MUSIC" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to voice searches, like "Play Security Now" -->
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to playlist-links based on file extension -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data android:pathPattern=".*\\.m3u" />
|
||||
<data android:pathPattern=".*\\.m3u8" />
|
||||
<data android:pathPattern=".*\\.pls" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to playlist-links based on mimetype -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="audio/x-scpls" />
|
||||
<data android:mimeType="audio/mpegurl" />
|
||||
<data android:mimeType="audio/x-mpegurl" />
|
||||
<data android:mimeType="application/pls+xml" />
|
||||
<data android:mimeType="application/x-mpegurl" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl.audio" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to hls playlist-links based on mimetype -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:scheme="content" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="audio/x-scpls" />
|
||||
<data android:mimeType="audio/mpegurl" />
|
||||
<data android:mimeType="audio/x-mpegurl" />
|
||||
<data android:mimeType="application/pls+xml" />
|
||||
<data android:mimeType="application/x-mpegurl" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl.audio" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- react to "start player service" intents -->
|
||||
<intent-filter>
|
||||
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- App Shortcuts -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
<!-- Player Service -->
|
||||
<service
|
||||
android:name=".PlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
|
||||
<!-- handles completed downloads -->
|
||||
<receiver
|
||||
android:name=".helpers.DownloadFinishedReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
<!-- handles media buttons -->
|
||||
<receiver
|
||||
android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
<!-- file provider -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
170
app/src/main/java/com/michatec/radio/Keys.kt
Normal file
170
app/src/main/java/com/michatec/radio/Keys.kt
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Keys.kt
|
||||
* Implements the keys used throughout the app
|
||||
* This object hosts all keys used to control Radio state
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* Keys object
|
||||
*/
|
||||
object Keys {
|
||||
|
||||
// version numbers
|
||||
const val CURRENT_COLLECTION_CLASS_VERSION_NUMBER: Int = 0
|
||||
|
||||
// time values
|
||||
const val SLEEP_TIMER_DURATION = "SLEEP_TIMER_DURATION"
|
||||
const val RECONNECTION_WAIT_INTERVAL: Long = 5000L // 5 seconds in milliseconds
|
||||
|
||||
// intent actions
|
||||
const val ACTION_SHOW_PLAYER: String = "com.michatec.radio.action.SHOW_PLAYER"
|
||||
const val ACTION_COLLECTION_CHANGED: String = "com.michatec.radio.action.COLLECTION_CHANGED"
|
||||
const val ACTION_START: String = "com.michatec.radio.action.START"
|
||||
|
||||
// intent extras
|
||||
const val EXTRA_COLLECTION_MODIFICATION_DATE: String = "COLLECTION_MODIFICATION_DATE"
|
||||
const val EXTRA_STATION_UUID: String = "STATION_UUID"
|
||||
const val EXTRA_STREAM_URI: String = "STREAM_URI"
|
||||
const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION"
|
||||
const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
|
||||
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
|
||||
|
||||
// arguments
|
||||
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
|
||||
const val ARG_UPDATE_IMAGES: String = "ArgUpdateImages"
|
||||
const val ARG_RESTORE_COLLECTION: String = "ArgRestoreCollection"
|
||||
|
||||
// keys
|
||||
const val KEY_SAVE_INSTANCE_STATE_STATION_LIST: String = "SAVE_INSTANCE_STATE_STATION_LIST"
|
||||
const val KEY_STREAM_URI: String = "STREAM_URI"
|
||||
|
||||
// custom MediaController commands
|
||||
const val CMD_START_SLEEP_TIMER: String = "START_SLEEP_TIMER"
|
||||
const val CMD_CANCEL_SLEEP_TIMER: String = "CANCEL_SLEEP_TIMER"
|
||||
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
|
||||
const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING"
|
||||
const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY"
|
||||
|
||||
// preferences
|
||||
const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API"
|
||||
const val PREF_ONE_TIME_HOUSEKEEPING_NECESSARY: String = "ONE_TIME_HOUSEKEEPING_NECESSARY_VERSIONCODE_95" // increment to current app version code to trigger housekeeping that runs only once
|
||||
const val PREF_THEME_SELECTION: String = "THEME_SELECTION"
|
||||
const val PREF_LAST_UPDATE_COLLECTION: String = "LAST_UPDATE_COLLECTION"
|
||||
const val PREF_COLLECTION_SIZE: String = "COLLECTION_SIZE"
|
||||
const val PREF_COLLECTION_MODIFICATION_DATE: String = "COLLECTION_MODIFICATION_DATE"
|
||||
const val PREF_ACTIVE_DOWNLOADS: String = "ACTIVE_DOWNLOADS"
|
||||
const val PREF_DOWNLOAD_OVER_MOBILE: String = "DOWNLOAD_OVER_MOBILE"
|
||||
const val PREF_STATION_LIST_EXPANDED_UUID = "STATION_LIST_EXPANDED_UUID"
|
||||
const val PREF_PLAYER_STATE_STATION_UUID: String = "PLAYER_STATE_STATION_UUID"
|
||||
const val PREF_PLAYER_STATE_IS_PLAYING: String = "PLAYER_STATE_IS_PLAYING"
|
||||
const val PREF_PLAYER_METADATA_HISTORY: String = "PLAYER_METADATA_HISTORY"
|
||||
const val PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING: String = "PLAYER_STATE_SLEEP_TIMER_RUNNING"
|
||||
const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE"
|
||||
const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS"
|
||||
const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS"
|
||||
|
||||
// default const values
|
||||
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25
|
||||
const val DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY: Int = 127
|
||||
const val DEFAULT_DOWNLOAD_OVER_MOBILE: Boolean = false
|
||||
const val ACTIVE_DOWNLOADS_EMPTY: String = "zero"
|
||||
const val DEFAULT_MAX_RECONNECTION_COUNT: Int = 30
|
||||
const val LARGE_BUFFER_SIZE_MULTIPLIER: Int = 8
|
||||
|
||||
// view types
|
||||
const val VIEW_TYPE_ADD_NEW: Int = 1
|
||||
const val VIEW_TYPE_STATION: Int = 2
|
||||
|
||||
// view holder update types
|
||||
const val HOLDER_UPDATE_COVER: Int = 0
|
||||
const val HOLDER_UPDATE_NAME: Int = 1
|
||||
const val HOLDER_UPDATE_PLAYBACK_STATE: Int = 2
|
||||
const val HOLDER_UPDATE_DOWNLOAD_STATE: Int = 3
|
||||
const val HOLDER_UPDATE_PLAYBACK_PROGRESS: Int = 4
|
||||
|
||||
// dialog types
|
||||
const val DIALOG_UPDATE_COLLECTION: Int = 1
|
||||
const val DIALOG_REMOVE_STATION: Int = 2
|
||||
const val DIALOG_UPDATE_STATION_IMAGES: Int = 4
|
||||
const val DIALOG_RESTORE_COLLECTION: Int = 5
|
||||
|
||||
// dialog results
|
||||
const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
|
||||
const val DIALOG_EMPTY_PAYLOAD_INT: Int = -1
|
||||
|
||||
// search types
|
||||
const val SEARCH_TYPE_BY_KEYWORD = 0
|
||||
const val SEARCH_TYPE_BY_UUID = 1
|
||||
|
||||
// file types
|
||||
const val FILE_TYPE_PLAYLIST: Int = 10
|
||||
const val FILE_TYPE_AUDIO: Int = 20
|
||||
const val FILE_TYPE_IMAGE: Int = 3
|
||||
|
||||
// mime types and charsets and file extensions
|
||||
const val CHARSET_UNDEFINDED = "undefined"
|
||||
const val MIME_TYPE_JPG = "image/jpeg"
|
||||
const val MIME_TYPE_PNG = "image/png"
|
||||
const val MIME_TYPE_M3U = "audio/x-mpegurl"
|
||||
const val MIME_TYPE_PLS = "audio/x-scpls"
|
||||
const val MIME_TYPE_ZIP = "application/zip"
|
||||
const val MIME_TYPE_OCTET_STREAM = "application/octet-stream"
|
||||
const val MIME_TYPE_UNSUPPORTED = "unsupported"
|
||||
val MIME_TYPES_M3U = arrayOf("application/mpegurl", "application/x-mpegurl", "audio/mpegurl", "audio/x-mpegurl")
|
||||
val MIME_TYPES_PLS = arrayOf("audio/x-scpls", "application/pls+xml")
|
||||
val MIME_TYPES_HLS = arrayOf("application/vnd.apple.mpegurl", "application/vnd.apple.mpegurl.audio")
|
||||
val MIME_TYPES_MPEG = arrayOf("audio/mpeg")
|
||||
val MIME_TYPES_OGG = arrayOf("audio/ogg", "application/ogg", "audio/opus")
|
||||
val MIME_TYPES_AAC = arrayOf("audio/aac", "audio/aacp")
|
||||
val MIME_TYPES_IMAGE = arrayOf("image/png", "image/jpeg")
|
||||
val MIME_TYPES_FAVICON = arrayOf("image/x-icon", "image/vnd.microsoft.icon")
|
||||
val MIME_TYPES_ZIP = arrayOf("application/zip", "application/x-zip-compressed", "multipart/x-zip")
|
||||
|
||||
// folder names
|
||||
const val FOLDER_COLLECTION: String = "collection"
|
||||
const val FOLDER_AUDIO: String = "audio"
|
||||
const val FOLDER_IMAGES: String = "images"
|
||||
const val FOLDER_TEMP: String = "temp"
|
||||
const val URLRADIO_LEGACY_FOLDER_COLLECTION: String = "Collection"
|
||||
|
||||
// file names and extensions
|
||||
const val COLLECTION_FILE: String = "collection.json"
|
||||
const val COLLECTION_M3U_FILE: String = "collection.m3u"
|
||||
const val COLLECTION_PLS_FILE: String = "collection.pls"
|
||||
const val STATION_IMAGE_FILE: String = "station-image.jpg"
|
||||
|
||||
// server addresses
|
||||
const val RADIO_BROWSER_API_BASE: String = "all.api.radio-browser.info"
|
||||
const val RADIO_BROWSER_API_DEFAULT: String = "de1.api.radio-browser.info"
|
||||
|
||||
// locations
|
||||
const val LOCATION_DEFAULT_STATION_IMAGE: String = "android.resource://com.michatec.radio/drawable/ic_default_station_image_24dp"
|
||||
|
||||
// sizes (in dp)
|
||||
const val SIZE_STATION_IMAGE_CARD: Int = 72
|
||||
const val SIZE_STATION_IMAGE_MAXIMUM: Int = 640
|
||||
const val BOTTOM_SHEET_PEEK_HEIGHT: Int = 72
|
||||
|
||||
// default values
|
||||
val DEFAULT_DATE: Date = Date(0L)
|
||||
const val EMPTY_STRING_RESOURCE: Int = 0
|
||||
|
||||
// theme states
|
||||
const val STATE_THEME_FOLLOW_SYSTEM: String = "stateFollowSystem"
|
||||
const val STATE_THEME_LIGHT_MODE: String = "stateLightMode"
|
||||
const val STATE_THEME_DARK_MODE: String = "stateDarkMode"
|
||||
|
||||
}
|
||||
108
app/src/main/java/com/michatec/radio/MainActivity.kt
Normal file
108
app/src/main/java/com/michatec/radio/MainActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* MainActivity.kt
|
||||
* Implements the MainActivity class
|
||||
* MainActivity is the default activity that can host the player fragment and the settings fragment
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.NavigationUI
|
||||
import androidx.navigation.ui.navigateUp
|
||||
import com.michatec.radio.helpers.AppThemeHelper
|
||||
import com.michatec.radio.helpers.FileHelper
|
||||
import com.michatec.radio.helpers.ImportHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper
|
||||
|
||||
|
||||
/*
|
||||
* MainActivity class
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
|
||||
|
||||
/* Overrides onCreate from AppCompatActivity */
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// house-keeping: determine if edit stations is enabled by default todo: remove in 2023
|
||||
if (PreferencesHelper.isHouseKeepingNecessary()) {
|
||||
// house-keeping 1: remove hard coded default image
|
||||
ImportHelper.removeDefaultStationImageUris(this)
|
||||
// house-keeping 2: if existing user detected, enable Edit Stations by default
|
||||
if (PreferencesHelper.loadCollectionSize() != -1) {
|
||||
// existing user detected - enable Edit Stations by default
|
||||
PreferencesHelper.saveEditStationsEnabled(true)
|
||||
}
|
||||
PreferencesHelper.saveHouseKeepingNecessaryState()
|
||||
}
|
||||
|
||||
// set up views
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// create .nomedia file - if not yet existing
|
||||
FileHelper.createNomediaFile(getExternalFilesDir(null))
|
||||
|
||||
// set up action bar
|
||||
setSupportActionBar(findViewById(R.id.main_toolbar))
|
||||
val toolbar: Toolbar = findViewById(R.id.main_toolbar)
|
||||
val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
appBarConfiguration = AppBarConfiguration(navController.graph)
|
||||
NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration)
|
||||
supportActionBar?.hide()
|
||||
|
||||
// register listener for changes in shared preferences
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onSupportNavigateUp from AppCompatActivity */
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
// Taken from: https://developer.android.com/guide/navigation/navigation-ui#action_bar
|
||||
val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onDestroy from AppCompatActivity */
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// unregister listener for changes in shared preferences
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Defines the listener for changes in shared preferences
|
||||
*/
|
||||
private val sharedPreferenceChangeListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_THEME_SELECTION -> {
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of declaration
|
||||
*/
|
||||
|
||||
}
|
||||
828
app/src/main/java/com/michatec/radio/PlayerFragment.kt
Normal file
828
app/src/main/java/com/michatec/radio/PlayerFragment.kt
Normal file
@@ -0,0 +1,828 @@
|
||||
/*
|
||||
* PlayerFragment.kt
|
||||
* Implements the PlayerFragment class
|
||||
* PlayerFragment is the fragment that hosts Radio's list of stations and a player sheet
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.android.volley.Request
|
||||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_KEYBOARD
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.michatec.radio.collection.CollectionAdapter
|
||||
import com.michatec.radio.collection.CollectionViewModel
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.dialogs.AddStationDialog
|
||||
import com.michatec.radio.dialogs.FindStationDialog
|
||||
import com.michatec.radio.dialogs.YesNoDialog
|
||||
import com.michatec.radio.extensions.*
|
||||
import com.michatec.radio.helpers.*
|
||||
import com.michatec.radio.ui.LayoutHolder
|
||||
import com.michatec.radio.ui.PlayerState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* PlayerFragment class
|
||||
*/
|
||||
class PlayerFragment : Fragment(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
FindStationDialog.FindStationDialogListener,
|
||||
AddStationDialog.AddStationDialogListener,
|
||||
CollectionAdapter.CollectionAdapterListener,
|
||||
YesNoDialog.YesNoDialogListener {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = PlayerFragment::class.java.simpleName
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var collectionViewModel: CollectionViewModel
|
||||
private lateinit var layout: LayoutHolder
|
||||
private lateinit var collectionAdapter: CollectionAdapter
|
||||
private lateinit var controllerFuture: ListenableFuture<MediaController>
|
||||
private lateinit var pickSingleMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>
|
||||
private lateinit var queue: RequestQueue
|
||||
private val controller: MediaController?
|
||||
get() = if (controllerFuture.isDone) controllerFuture.get() else null // defines the Getter for the MediaController
|
||||
private var collection: Collection = Collection()
|
||||
private var playerState: PlayerState = PlayerState()
|
||||
private var listLayoutState: Parcelable? = null
|
||||
private val handler: Handler = Handler(Looper.getMainLooper())
|
||||
private var tempStationUuid: String = String()
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
|
||||
|
||||
/* Overrides onCreate from Fragment */
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// handle back tap/gesture
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
// minimize player sheet - or if already minimized let activity handle back
|
||||
if (isEnabled && this@PlayerFragment::layout.isInitialized && !layout.minimizePlayerIfExpanded()) {
|
||||
isEnabled = false
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
queue = Volley.newRequestQueue(requireActivity())
|
||||
|
||||
// load player state
|
||||
playerState = PreferencesHelper.loadPlayerState()
|
||||
|
||||
// create view model and observe changes in collection view model
|
||||
collectionViewModel = ViewModelProvider(this)[CollectionViewModel::class.java]
|
||||
|
||||
// create collection adapter
|
||||
collectionAdapter = CollectionAdapter(
|
||||
activity as Context,
|
||||
this as CollectionAdapter.CollectionAdapterListener
|
||||
)
|
||||
|
||||
// restore state of station list
|
||||
listLayoutState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
savedInstanceState?.getParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST, Parcelable::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
savedInstanceState?.getParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST)
|
||||
}
|
||||
|
||||
// Initialize single media picker launcher
|
||||
pickSingleMediaLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { imageUri ->
|
||||
if (imageUri == null) {
|
||||
Snackbar.make(requireView(), R.string.toastalert_failed_picking_media, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
collection = CollectionHelper.setStationImageWithStationUuid(
|
||||
activity as Context,
|
||||
collection,
|
||||
imageUri,
|
||||
tempStationUuid,
|
||||
imageManuallySet = true
|
||||
)
|
||||
tempStationUuid = String()
|
||||
}
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({ context?.let { checkForUpdates() } }, 5000)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onCreate from Fragment */
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
// find views and set them up
|
||||
val rootView: View = inflater.inflate(R.layout.fragment_player, container, false)
|
||||
layout = LayoutHolder(rootView)
|
||||
|
||||
initializeViews()
|
||||
|
||||
// hide action bar
|
||||
(activity as AppCompatActivity).supportActionBar?.hide()
|
||||
|
||||
// set the same background color of the player sheet for the navigation bar
|
||||
(activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireActivity(), R.color.player_sheet_background)
|
||||
|
||||
// associate the ItemTouchHelper with the RecyclerView
|
||||
itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback())
|
||||
itemTouchHelper?.attachToRecyclerView(layout.recyclerView)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
|
||||
/* Implement the ItemTouchHelper.Callback for drag and drop functionality */
|
||||
inner class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
|
||||
|
||||
override fun isLongPressDragEnabled() = !collectionAdapter.isExpandedForEdit
|
||||
|
||||
override fun isItemViewSwipeEnabled() = true
|
||||
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
// disable drag and drop for the new card
|
||||
if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
val swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
|
||||
return makeMovementFlags(dragFlags, swipeFlags)
|
||||
}
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
val fromPosition = viewHolder.adapterPosition
|
||||
val toPosition = target.adapterPosition
|
||||
collectionAdapter.onItemMove(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.adapterPosition
|
||||
collectionAdapter.onItemDismiss(position)
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
collectionAdapter.saveCollectionAfterDragDrop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onStart from Fragment */
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// initialize MediaController - connect to PlayerService
|
||||
initializeController()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onSaveInstanceState from Fragment */
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
if (this::layout.isInitialized) {
|
||||
// save current state of station list
|
||||
listLayoutState = layout.layoutManager.onSaveInstanceState()
|
||||
outState.putParcelable(Keys.KEY_SAVE_INSTANCE_STATE_STATION_LIST, listLayoutState)
|
||||
}
|
||||
// always call the superclass so it can save the view hierarchy state
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onResume from Fragment */
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// assign volume buttons to music volume
|
||||
activity?.volumeControlStream = AudioManager.STREAM_MUSIC
|
||||
// load player state
|
||||
playerState = PreferencesHelper.loadPlayerState()
|
||||
// recreate player ui
|
||||
// setupPlaybackControls()
|
||||
updatePlayerViews()
|
||||
updateStationListState()
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
// begin looking for changes in collection
|
||||
observeCollectionViewModel()
|
||||
// handle navigation arguments
|
||||
handleNavigationArguments()
|
||||
// // handle start intent - if started via tap on rss link
|
||||
// handleStartIntent()
|
||||
// start watching for changes in shared preferences
|
||||
PreferencesHelper.registerPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onPause from Fragment */
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// stop receiving playback progress updates
|
||||
handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable)
|
||||
// stop watching for changes in shared preferences
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(this as SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onStop from Fragment */
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// release MediaController - cut connection to PlayerService
|
||||
releaseController()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
queue.cancelAll(TAG)
|
||||
}
|
||||
|
||||
/* Overrides onSharedPreferenceChanged from OnSharedPreferenceChangeListener */
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == Keys.PREF_ACTIVE_DOWNLOADS) {
|
||||
layout.toggleDownloadProgressIndicator()
|
||||
}
|
||||
if (key == Keys.PREF_PLAYER_METADATA_HISTORY) {
|
||||
requestMetadataUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onFindStationDialog from FindStationDialog */
|
||||
override fun onFindStationDialog(station: Station) {
|
||||
if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
// add station and save collection
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
} else {
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val contentType: NetworkHelper.ContentType = NetworkHelper.detectContentType(station.getStreamUri())
|
||||
// set content type
|
||||
station.streamContent = contentType.type
|
||||
// add station and save collection
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onAddStationDialog from AddDialog */
|
||||
override fun onAddStationDialog(station: Station) {
|
||||
if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
// add station and save collection
|
||||
collection = CollectionHelper.addStation(activity as Context, collection, station)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onPlayButtonTapped from CollectionAdapterListener */
|
||||
override fun onPlayButtonTapped(stationUuid: String) {
|
||||
// CASE: the selected station is playing
|
||||
if (controller?.isPlaying == true && stationUuid == playerState.stationUuid) {
|
||||
// stop playback
|
||||
controller?.pause()
|
||||
}
|
||||
// CASE: the selected station is not playing (another station might be playing)
|
||||
else {
|
||||
// start playback
|
||||
controller?.play(activity as Context, CollectionHelper.getStation(collection, stationUuid))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onAddNewButtonTapped from CollectionAdapterListener */
|
||||
override fun onAddNewButtonTapped() {
|
||||
FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onChangeImageButtonTapped from CollectionAdapterListener */
|
||||
override fun onChangeImageButtonTapped(stationUuid: String) {
|
||||
tempStationUuid = stationUuid
|
||||
pickImage()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onYesNoDialog from YesNoDialogListener */
|
||||
override fun onYesNoDialog(
|
||||
type: Int,
|
||||
dialogResult: Boolean,
|
||||
payload: Int,
|
||||
payloadString: String
|
||||
) {
|
||||
super.onYesNoDialog(type, dialogResult, payload, payloadString)
|
||||
when (type) {
|
||||
// handle result of remove dialog
|
||||
Keys.DIALOG_REMOVE_STATION -> {
|
||||
when (dialogResult) {
|
||||
// user tapped remove station
|
||||
true -> collectionAdapter.removeStation(activity as Context, payload)
|
||||
// user tapped cancel
|
||||
false -> collectionAdapter.notifyItemChanged(payload)
|
||||
}
|
||||
}
|
||||
// handle result from the restore collection dialog
|
||||
Keys.DIALOG_RESTORE_COLLECTION -> {
|
||||
when (dialogResult) {
|
||||
// user tapped restore
|
||||
true -> BackupHelper.restore(requireView(), activity as Context, payloadString.toUri())
|
||||
// user tapped cancel
|
||||
false -> {
|
||||
/* do nothing */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Initializes the MediaController - handles connection to PlayerService under the hood */
|
||||
private fun initializeController() {
|
||||
controllerFuture = MediaController.Builder(
|
||||
activity as Context,
|
||||
SessionToken(
|
||||
activity as Context,
|
||||
ComponentName(activity as Context, PlayerService::class.java)
|
||||
)
|
||||
).buildAsync()
|
||||
controllerFuture.addListener({ setupController() }, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
|
||||
/* Releases MediaController */
|
||||
private fun releaseController() {
|
||||
MediaController.releaseFuture(controllerFuture)
|
||||
}
|
||||
|
||||
|
||||
/* Sets up the MediaController */
|
||||
private fun setupController() {
|
||||
val controller: MediaController = this.controller ?: return
|
||||
controller.addListener(playerListener)
|
||||
requestMetadataUpdate()
|
||||
// handle start intent
|
||||
handleStartIntent()
|
||||
}
|
||||
|
||||
|
||||
/* Sets up views and connects tap listeners - first run */
|
||||
private fun initializeViews() {
|
||||
// set adapter data source
|
||||
layout.recyclerView.adapter = collectionAdapter
|
||||
|
||||
// enable swipe to delete
|
||||
val swipeToDeleteHandler = object : UiHelper.SwipeToDeleteCallback(activity as Context) {
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
// ask user
|
||||
val adapterPosition: Int = viewHolder.adapterPosition
|
||||
val dialogMessage =
|
||||
"${getString(R.string.dialog_yes_no_message_remove_station)}\n\n- ${collection.stations[adapterPosition].name}"
|
||||
YesNoDialog(this@PlayerFragment as YesNoDialog.YesNoDialogListener).show(
|
||||
context = activity as Context,
|
||||
type = Keys.DIALOG_REMOVE_STATION,
|
||||
messageString = dialogMessage,
|
||||
yesButton = R.string.dialog_yes_no_positive_button_remove_station,
|
||||
payload = adapterPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
val swipeToDeleteItemTouchHelper = ItemTouchHelper(swipeToDeleteHandler)
|
||||
swipeToDeleteItemTouchHelper.attachToRecyclerView(layout.recyclerView)
|
||||
|
||||
// enable swipe to mark starred
|
||||
val swipeToMarkStarredHandler =
|
||||
object : UiHelper.SwipeToMarkStarredCallback(activity as Context) {
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
// mark card starred
|
||||
val adapterPosition: Int = viewHolder.adapterPosition
|
||||
collectionAdapter.toggleStarredStation(activity as Context, adapterPosition)
|
||||
}
|
||||
}
|
||||
val swipeToMarkStarredItemTouchHelper = ItemTouchHelper(swipeToMarkStarredHandler)
|
||||
swipeToMarkStarredItemTouchHelper.attachToRecyclerView(layout.recyclerView)
|
||||
|
||||
// set up sleep timer start button
|
||||
layout.sheetSleepTimerStartButtonView.setOnClickListener {
|
||||
when (controller?.isPlaying) {
|
||||
true -> {
|
||||
val timePicker = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(TimeFormat.CLOCK_24H)
|
||||
.setHour(0)
|
||||
.setMinute(1)
|
||||
.setInputMode(INPUT_MODE_KEYBOARD)
|
||||
.build()
|
||||
|
||||
timePicker.addOnPositiveButtonClickListener {
|
||||
val selectedTimeMillis = (timePicker.hour * 60 * 60 * 1000L) + (timePicker.minute * 60 * 1000L) + 1000
|
||||
// start the sleep timer with the selected time
|
||||
playerState.sleepTimerRunning = true
|
||||
controller?.startSleepTimer(selectedTimeMillis)
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
}
|
||||
|
||||
// display the TimePicker dialog
|
||||
timePicker.show(requireActivity().supportFragmentManager, "tag")
|
||||
}
|
||||
else -> Snackbar.make(
|
||||
requireView(),
|
||||
R.string.toastmessage_sleep_timer_unable_to_start,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// set up sleep timer cancel button
|
||||
layout.sheetSleepTimerCancelButtonView.setOnClickListener {
|
||||
playerState.sleepTimerRunning = false
|
||||
controller?.cancelSleepTimer()
|
||||
togglePeriodicSleepTimerUpdateRequest()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// /* Sets up the general playback controls - Note: station specific controls and views are updated in updatePlayerViews() */
|
||||
// // it is probably okay to suppress this warning - the OnTouchListener on the time played view does only toggle the time duration / remaining display
|
||||
// private fun setupPlaybackControls() {
|
||||
//
|
||||
// // main play/pause button
|
||||
// layout.playButtonView.setOnClickListener {
|
||||
// onPlayButtonTapped(playerState.stationUuid, playerState.playbackState)
|
||||
// //onPlayButtonTapped(playerState.stationUuid, playerController.getPlaybackState().state) // todo remove
|
||||
// }
|
||||
//
|
||||
// // register a callback to stay in sync
|
||||
// playerController.registerCallback(mediaControllerCallback)
|
||||
// }
|
||||
|
||||
|
||||
/* Sets up the player */
|
||||
private fun updatePlayerViews() {
|
||||
// get station
|
||||
var station = Station()
|
||||
if (playerState.stationUuid.isNotEmpty()) {
|
||||
// get station from player state
|
||||
station = CollectionHelper.getStation(collection, playerState.stationUuid)
|
||||
} else if (collection.stations.isNotEmpty()) {
|
||||
// fallback: get first station
|
||||
station = collection.stations[0]
|
||||
playerState.stationUuid = station.uuid
|
||||
}
|
||||
// update views
|
||||
layout.togglePlayButton(playerState.isPlaying)
|
||||
layout.updatePlayerViews(activity as Context, station, playerState.isPlaying)
|
||||
|
||||
// main play/pause button
|
||||
layout.playButtonView.setOnClickListener {
|
||||
onPlayButtonTapped(playerState.stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sets up state of list station list */
|
||||
private fun updateStationListState() {
|
||||
if (listLayoutState != null) {
|
||||
layout.layoutManager.onRestoreInstanceState(listLayoutState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Requests an update of the sleep timer from the player service */
|
||||
private fun requestSleepTimerUpdate() {
|
||||
val resultFuture: ListenableFuture<SessionResult>? =
|
||||
controller?.requestSleepTimerRemaining()
|
||||
resultFuture?.addListener(Runnable {
|
||||
val timeRemaining: Long = resultFuture.get().extras.getLong(Keys.EXTRA_SLEEP_TIMER_REMAINING)
|
||||
layout.updateSleepTimer(activity as Context, timeRemaining)
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
|
||||
/* Requests an update of the metadata history from the player service */
|
||||
private fun requestMetadataUpdate() {
|
||||
val resultFuture: ListenableFuture<SessionResult>? = controller?.requestMetadataHistory()
|
||||
resultFuture?.addListener(Runnable {
|
||||
val metadata: ArrayList<String>? = resultFuture.get().extras.getStringArrayList(Keys.EXTRA_METADATA_HISTORY)
|
||||
layout.updateMetadata(metadata?.toMutableList())
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
|
||||
/* Start image picker */
|
||||
private fun pickImage() {
|
||||
pickSingleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
|
||||
/* Handles this activity's start intent */
|
||||
private fun handleStartIntent() {
|
||||
if ((activity as Activity).intent.action != null) {
|
||||
when ((activity as Activity).intent.action) {
|
||||
Keys.ACTION_SHOW_PLAYER -> handleShowPlayer()
|
||||
Intent.ACTION_VIEW -> handleViewIntent()
|
||||
Keys.ACTION_START -> handleStartPlayer()
|
||||
}
|
||||
}
|
||||
// clear intent action to prevent double calls
|
||||
(activity as Activity).intent.action = ""
|
||||
}
|
||||
|
||||
|
||||
/* Handles ACTION_SHOW_PLAYER request from notification */
|
||||
private fun handleShowPlayer() {
|
||||
Log.i(TAG, "Tap on notification registered.")
|
||||
// todo implement
|
||||
}
|
||||
|
||||
|
||||
/* Handles ACTION_VIEW request to add Station */
|
||||
private fun handleViewIntent() {
|
||||
val intentUri: Uri? = (activity as Activity).intent.data
|
||||
if (intentUri != null) {
|
||||
CoroutineScope(IO).launch {
|
||||
// get station list from intent source
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val scheme: String = intentUri.scheme ?: String()
|
||||
// CASE: intent is a web link
|
||||
if (scheme.startsWith("http")) {
|
||||
Log.i(TAG, "Transistor was started to handle a web link.")
|
||||
stationList.addAll(CollectionHelper.createStationsFromUrl(intentUri.toString()))
|
||||
}
|
||||
// CASE: intent is a local file
|
||||
else if (scheme.startsWith("content")) {
|
||||
Log.i(TAG, "Transistor was started to handle a local audio playlist.")
|
||||
stationList.addAll(CollectionHelper.createStationListFromContentUri(activity as Context, intentUri))
|
||||
}
|
||||
withContext(Main) {
|
||||
if (stationList.isNotEmpty()) {
|
||||
AddStationDialog(activity as Activity, stationList, this@PlayerFragment as AddStationDialog.AddStationDialogListener).show()
|
||||
} else {
|
||||
// invalid address
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Handles START_PLAYER_SERVICE request from App Shortcut */
|
||||
private fun handleStartPlayer() {
|
||||
val intent: Intent = (activity as Activity).intent
|
||||
if (intent.hasExtra(Keys.EXTRA_START_LAST_PLAYED_STATION)) {
|
||||
controller?.play(activity as Context, CollectionHelper.getStation(collection, playerState.stationUuid))
|
||||
} else if (intent.hasExtra(Keys.EXTRA_STATION_UUID)) {
|
||||
val uuid: String = intent.getStringExtra(Keys.EXTRA_STATION_UUID) ?: String()
|
||||
controller?.play(activity as Context, CollectionHelper.getStation(collection, uuid))
|
||||
} else if (intent.hasExtra(Keys.EXTRA_STREAM_URI)) {
|
||||
val streamUri: String = intent.getStringExtra(Keys.EXTRA_STREAM_URI) ?: String()
|
||||
controller?.playStreamDirectly(streamUri)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Toggle periodic update request of Sleep Timer state from player service */
|
||||
private fun togglePeriodicSleepTimerUpdateRequest() {
|
||||
handler.removeCallbacks(periodicSleepTimerUpdateRequestRunnable)
|
||||
handler.postDelayed(periodicSleepTimerUpdateRequestRunnable, 0)
|
||||
}
|
||||
|
||||
|
||||
/* Observe view model of collection of stations */
|
||||
private fun observeCollectionViewModel() {
|
||||
collectionViewModel.collectionLiveData.observe(this) {
|
||||
// update collection
|
||||
collection = it
|
||||
//// // updates current station in player views
|
||||
//// playerState = PreferencesHelper.loadPlayerState()
|
||||
// // get station
|
||||
// val station: Station = CollectionHelper.getStation(collection, playerState.stationUuid)
|
||||
// // update player views
|
||||
// layout.updatePlayerViews(activity as Context, station, playerState.isPlaying)
|
||||
//// // handle start intent
|
||||
//// handleStartIntent()
|
||||
//// // handle navigation arguments
|
||||
//// handleNavigationArguments()
|
||||
}
|
||||
collectionViewModel.collectionSizeLiveData.observe(this) {
|
||||
// size of collection changed
|
||||
layout.toggleOnboarding(activity as Context, collection.stations.size)
|
||||
updatePlayerViews()
|
||||
CollectionHelper.exportCollectionM3u(activity as Context, collection)
|
||||
CollectionHelper.exportCollectionPls(activity as Context, collection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Handles arguments handed over by navigation (from SettingsFragment) */
|
||||
private fun handleNavigationArguments() {
|
||||
// get arguments
|
||||
val updateCollection: Boolean =
|
||||
arguments?.getBoolean(Keys.ARG_UPDATE_COLLECTION, false) ?: false
|
||||
val updateStationImages: Boolean =
|
||||
arguments?.getBoolean(Keys.ARG_UPDATE_IMAGES, false) ?: false
|
||||
val restoreCollectionFileString: String? = arguments?.getString(Keys.ARG_RESTORE_COLLECTION)
|
||||
|
||||
if (updateCollection) {
|
||||
arguments?.putBoolean(Keys.ARG_UPDATE_COLLECTION, false)
|
||||
val updateHelper = UpdateHelper(activity as Context, collectionAdapter, collection)
|
||||
updateHelper.updateCollection()
|
||||
}
|
||||
if (updateStationImages) {
|
||||
arguments?.putBoolean(Keys.ARG_UPDATE_IMAGES, false)
|
||||
DownloadHelper.updateStationImages(activity as Context)
|
||||
}
|
||||
if (!restoreCollectionFileString.isNullOrEmpty()) {
|
||||
arguments?.putString(Keys.ARG_RESTORE_COLLECTION, null)
|
||||
when (collection.stations.isNotEmpty()) {
|
||||
true -> {
|
||||
YesNoDialog(this as YesNoDialog.YesNoDialogListener).show(
|
||||
context = activity as Context,
|
||||
type = Keys.DIALOG_RESTORE_COLLECTION,
|
||||
messageString = getString(R.string.dialog_restore_collection_replace_existing),
|
||||
payloadString = restoreCollectionFileString
|
||||
)
|
||||
}
|
||||
false -> {
|
||||
BackupHelper.restore(
|
||||
requireView(),
|
||||
activity as Context,
|
||||
restoreCollectionFileString.toUri()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Runnable: Periodically requests sleep timer state
|
||||
*/
|
||||
private val periodicSleepTimerUpdateRequestRunnable: Runnable = object : Runnable {
|
||||
override fun run() {
|
||||
// update sleep timer view
|
||||
requestSleepTimerUpdate()
|
||||
// use the handler to start runnable again after specified delay
|
||||
handler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of declaration
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Player.Listener: Called when one or more player states changed.
|
||||
*/
|
||||
private var playerListener: Player.Listener = object : Player.Listener {
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
super.onMediaItemTransition(mediaItem, reason)
|
||||
// store new station
|
||||
playerState.stationUuid = mediaItem?.mediaId ?: String()
|
||||
// update station specific views
|
||||
updatePlayerViews()
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
// store state of playback
|
||||
playerState.isPlaying = isPlaying
|
||||
// animate state transition of play button(s)
|
||||
layout.animatePlaybackButtonStateTransition(activity as Context, isPlaying)
|
||||
|
||||
if (isPlaying) {
|
||||
// playback is active
|
||||
layout.showPlayer(activity as Context)
|
||||
layout.showBufferingIndicator(buffering = false)
|
||||
} else {
|
||||
// playback is paused or stopped
|
||||
// check if buffering (playback is not active but playWhenReady is true)
|
||||
if (controller?.playWhenReady == true) {
|
||||
// playback is buffering, show the buffering indicator
|
||||
layout.showBufferingIndicator(buffering = true)
|
||||
} else {
|
||||
// playback is not buffering, hide the buffering indicator
|
||||
layout.showBufferingIndicator(buffering = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
||||
if (playWhenReady && controller?.isPlaying == false) {
|
||||
layout.showBufferingIndicator(buffering = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
super.onPlayerError(error)
|
||||
layout.togglePlayButton(false)
|
||||
layout.showBufferingIndicator(false)
|
||||
Toast.makeText(activity, R.string.toastmessage_connection_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Check for update on github
|
||||
*/
|
||||
private fun checkForUpdates() {
|
||||
val url = getString(R.string.snackbar_github_update_check_url)
|
||||
val request = StringRequest(Request.Method.GET, url, { reply ->
|
||||
val latestVersion = Gson().fromJson(reply, JsonObject::class.java).get("tag_name").asString
|
||||
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity?.packageManager?.getPackageInfo(requireActivity().packageName, PackageManager.PackageInfoFlags.of(0))?.versionName
|
||||
} else {
|
||||
activity?.packageManager?.getPackageInfo(requireActivity().packageName, 0)?.versionName
|
||||
}
|
||||
if (latestVersion != current) {
|
||||
// We have an update available, tell our user about it
|
||||
view?.let {
|
||||
Snackbar.make(it, getString(R.string.app_name) + " " + latestVersion + " " + getString(R.string.snackbar_update_available), 10000)
|
||||
.setAction(R.string.snackbar_show) {
|
||||
val releaseurl = getString(R.string.snackbar_url_app_home_page)
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = Uri.parse(releaseurl)
|
||||
// Not sure that does anything
|
||||
i.putExtra("SOURCE", "SELF")
|
||||
startActivity(i)
|
||||
}
|
||||
.setActionTextColor(
|
||||
ContextCompat.getColor(
|
||||
requireActivity(),
|
||||
R.color.default_neutral_white))
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}, { error ->
|
||||
Log.w(TAG, "Update check failed", error)
|
||||
})
|
||||
|
||||
request.tag = TAG
|
||||
queue.add(request)
|
||||
}
|
||||
|
||||
/*
|
||||
* End of declaration
|
||||
*/
|
||||
}
|
||||
|
||||
673
app/src/main/java/com/michatec/radio/PlayerService.kt
Normal file
673
app/src/main/java/com/michatec/radio/PlayerService.kt
Normal file
@@ -0,0 +1,673 @@
|
||||
/*
|
||||
* PlayerService.kt
|
||||
* Implements the PlayerService class
|
||||
* PlayerService is Radio's foreground service that plays radio station audio
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.*
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CountDownTimer
|
||||
import android.util.Log
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.session.*
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.helpers.AudioHelper
|
||||
import com.michatec.radio.helpers.CollectionHelper
|
||||
import com.michatec.radio.helpers.FileHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* PlayerService class
|
||||
*/
|
||||
@UnstableApi
|
||||
class PlayerService : MediaLibraryService() {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = PlayerService::class.java.simpleName
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var player: Player
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var sleepTimer: CountDownTimer
|
||||
var sleepTimerTimeRemaining: Long = 0L
|
||||
private var sleepTimerEndTime: Long = 0L
|
||||
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
|
||||
private var collection: Collection = Collection()
|
||||
private lateinit var metadataHistory: MutableList<String>
|
||||
private var bufferSizeMultiplier: Int = PreferencesHelper.loadBufferSizeMultiplier()
|
||||
private var playbackRestartCounter: Int = 0
|
||||
private var playLastStation: Boolean = false
|
||||
private var manuallyCancelledSleepTimer = false
|
||||
|
||||
|
||||
/* Overrides onCreate from Service */
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// load collection
|
||||
collection = FileHelper.readCollection(this)
|
||||
// create and register collection changed receiver
|
||||
LocalBroadcastManager.getInstance(application).registerReceiver(
|
||||
collectionChangedReceiver,
|
||||
IntentFilter(Keys.ACTION_COLLECTION_CHANGED)
|
||||
)
|
||||
// initialize player and session
|
||||
initializePlayer()
|
||||
initializeSession()
|
||||
val notificationProvider: DefaultMediaNotificationProvider = CustomNotificationProvider()
|
||||
notificationProvider.setSmallIcon(R.drawable.ic_notification_app_icon_white_24dp)
|
||||
setMediaNotificationProvider(notificationProvider)
|
||||
// fetch the metadata history
|
||||
metadataHistory = PreferencesHelper.loadMetadataHistory()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onDestroy from Service */
|
||||
override fun onDestroy() {
|
||||
// player.removeAnalyticsListener(analyticsListener)
|
||||
player.removeListener(playerListener)
|
||||
player.release()
|
||||
mediaLibrarySession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onTaskRemoved from Service */
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
if (!player.playWhenReady) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onGetSession from MediaSessionService */
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
|
||||
/* Initializes the ExoPlayer */
|
||||
private fun initializePlayer() {
|
||||
val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply {
|
||||
setAudioAttributes(AudioAttributes.DEFAULT, true)
|
||||
setHandleAudioBecomingNoisy(true)
|
||||
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
|
||||
setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this@PlayerService).setLoadErrorHandlingPolicy(
|
||||
loadErrorHandlingPolicy
|
||||
)
|
||||
)
|
||||
}.build()
|
||||
exoPlayer.addAnalyticsListener(analyticsListener)
|
||||
exoPlayer.addListener(playerListener)
|
||||
|
||||
// manually add seek to next and seek to previous since headphones issue them and they are translated to next and previous station
|
||||
player = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
|
||||
.add(COMMAND_SEEK_TO_PREVIOUS).build()
|
||||
}
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean {
|
||||
return availableCommands.contains(command)
|
||||
}
|
||||
|
||||
override fun getDuration(): Long {
|
||||
return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Initializes the MediaSession */
|
||||
private fun initializeSession() {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = TaskStackBuilder.create(this).run {
|
||||
addNextIntent(intent)
|
||||
getPendingIntent(0, if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_CANCEL_CURRENT)
|
||||
}
|
||||
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback).apply {
|
||||
setSessionActivity(pendingIntent)
|
||||
}.build()
|
||||
}
|
||||
|
||||
|
||||
/* Creates a LoadControl - increase buffer size by given factor */
|
||||
private fun createDefaultLoadControl(factor: Int): DefaultLoadControl {
|
||||
val builder = DefaultLoadControl.Builder()
|
||||
builder.setAllocator(DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE))
|
||||
builder.setBufferDurationsMs(
|
||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * factor,
|
||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * factor,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS * factor,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS * factor
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
||||
/* Starts sleep timer / adds default duration to running sleeptimer */
|
||||
private fun startSleepTimer(selectedTimeMillis: Long) {
|
||||
// stop running timer
|
||||
if (sleepTimerTimeRemaining > 0L && this::sleepTimer.isInitialized) {
|
||||
sleepTimer.cancel()
|
||||
}
|
||||
|
||||
// set the end time of the sleep timer
|
||||
sleepTimerEndTime = System.currentTimeMillis() + selectedTimeMillis
|
||||
|
||||
// initialize timer
|
||||
sleepTimer = object : CountDownTimer(selectedTimeMillis, 1000) {
|
||||
override fun onFinish() {
|
||||
Log.v(TAG, "Sleep timer finished. Sweet dreams.")
|
||||
sleepTimerTimeRemaining = 0L
|
||||
player.stop()
|
||||
}
|
||||
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
sleepTimerTimeRemaining = millisUntilFinished
|
||||
}
|
||||
}
|
||||
// start timer
|
||||
sleepTimer.start()
|
||||
// store timer state
|
||||
PreferencesHelper.saveSleepTimerRunning(isRunning = true)
|
||||
}
|
||||
|
||||
|
||||
/* Cancels sleep timer */
|
||||
private fun cancelSleepTimer() {
|
||||
if (this::sleepTimer.isInitialized) {
|
||||
if (manuallyCancelledSleepTimer) {
|
||||
sleepTimerTimeRemaining = 0L
|
||||
sleepTimer.cancel()
|
||||
}
|
||||
manuallyCancelledSleepTimer = false
|
||||
}
|
||||
// store timer state
|
||||
PreferencesHelper.saveSleepTimerRunning(isRunning = false)
|
||||
}
|
||||
|
||||
|
||||
/* Function to cancel the timer manually */
|
||||
fun manuallyCancelSleepTimer() {
|
||||
manuallyCancelledSleepTimer = true
|
||||
cancelSleepTimer()
|
||||
}
|
||||
|
||||
|
||||
/* Updates metadata */
|
||||
private fun updateMetadata(metadata: String = String()) {
|
||||
// get metadata string
|
||||
val metadataString: String = metadata.ifEmpty {
|
||||
player.currentMediaItem?.mediaMetadata?.artist.toString()
|
||||
}
|
||||
// remove duplicates
|
||||
if (metadataHistory.contains(metadataString)) {
|
||||
metadataHistory.removeAll { it == metadataString }
|
||||
}
|
||||
// append metadata to metadata history
|
||||
metadataHistory.add(metadataString)
|
||||
// trim metadata list
|
||||
if (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
|
||||
metadataHistory.removeAt(0)
|
||||
}
|
||||
// save history
|
||||
PreferencesHelper.saveMetadataHistory(metadataHistory)
|
||||
}
|
||||
|
||||
|
||||
/* Reads collection of stations from storage using GSON */
|
||||
private fun loadCollection(context: Context) {
|
||||
Log.v(TAG, "Loading collection of stations from storage")
|
||||
CoroutineScope(Main).launch {
|
||||
// load collection on background thread
|
||||
val deferred: Deferred<Collection> =
|
||||
async(Dispatchers.Default) { FileHelper.readCollectionSuspended(context) }
|
||||
// wait for result and update collection
|
||||
collection = deferred.await()
|
||||
// // special case: trigger metadata view update for stations that have no metadata
|
||||
// if (player.isPlaying && station.name == getCurrentMetadata()) {
|
||||
// station = CollectionHelper.getStation(collection, station.uuid)
|
||||
// updateMetadata(null)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Custom MediaSession Callback that handles player commands
|
||||
*/
|
||||
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
val updatedMediaItems: List<MediaItem> =
|
||||
mediaItems.map { mediaItem ->
|
||||
CollectionHelper.getItem(this@PlayerService, collection, mediaItem.mediaId)
|
||||
// if (mediaItem.requestMetadata.searchQuery != null)
|
||||
// getMediaItemFromSearchQuery(mediaItem.requestMetadata.searchQuery!!)
|
||||
// else MediaItemTree.getItem(mediaItem.mediaId) ?: mediaItem
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems)
|
||||
|
||||
|
||||
// val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
// mediaItem.buildUpon().apply {
|
||||
// setUri(mediaItem.requestMetadata.mediaUri)
|
||||
// }.build()
|
||||
// }
|
||||
// return Futures.immediateFuture(updatedMediaItems)
|
||||
}
|
||||
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
// add custom commands
|
||||
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
|
||||
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
|
||||
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
|
||||
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
|
||||
}
|
||||
|
||||
override fun onSubscribe(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<Void>> {
|
||||
val children: List<MediaItem> = CollectionHelper.getChildren(this@PlayerService, collection)
|
||||
session.notifyChildrenChanged(browser, parentId, children.size, params)
|
||||
return Futures.immediateFuture(LibraryResult.ofVoid())
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children: List<MediaItem> = CollectionHelper.getChildren(this@PlayerService, collection)
|
||||
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
return if (params?.extras?.containsKey(EXTRA_RECENT) == true) {
|
||||
// special case: system requested media resumption via EXTRA_RECENT
|
||||
playLastStation = true
|
||||
Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRecent(this@PlayerService, collection), params))
|
||||
} else {
|
||||
Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRootItem(), params))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val item: MediaItem = CollectionHelper.getItem(this@PlayerService, collection, mediaId)
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(item, /* params = */ null))
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
when (customCommand.customAction) {
|
||||
Keys.CMD_START_SLEEP_TIMER -> {
|
||||
val selectedTimeMillis = args.getLong(Keys.SLEEP_TIMER_DURATION)
|
||||
startSleepTimer(selectedTimeMillis)
|
||||
}
|
||||
Keys.CMD_CANCEL_SLEEP_TIMER -> {
|
||||
manuallyCancelSleepTimer()
|
||||
}
|
||||
Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING -> {
|
||||
val resultBundle = Bundle()
|
||||
resultBundle.putLong(Keys.EXTRA_SLEEP_TIMER_REMAINING, sleepTimerTimeRemaining)
|
||||
return Futures.immediateFuture(
|
||||
SessionResult(
|
||||
SessionResult.RESULT_SUCCESS,
|
||||
resultBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
Keys.CMD_REQUEST_METADATA_HISTORY -> {
|
||||
val resultBundle = Bundle()
|
||||
resultBundle.putStringArrayList(
|
||||
Keys.EXTRA_METADATA_HISTORY,
|
||||
ArrayList(metadataHistory)
|
||||
)
|
||||
return Futures.immediateFuture(
|
||||
SessionResult(
|
||||
SessionResult.RESULT_SUCCESS,
|
||||
resultBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
override fun onPlayerCommandRequest(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
playerCommand: Int
|
||||
): Int {
|
||||
// playerCommand = one of COMMAND_PLAY_PAUSE, COMMAND_PREPARE, COMMAND_STOP, COMMAND_SEEK_TO_DEFAULT_POSITION, COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD, COMMAND_SET_SPEED_AND_PITCH, COMMAND_SET_SHUFFLE_MODE, COMMAND_SET_REPEAT_MODE, COMMAND_GET_CURRENT_MEDIA_ITEM, COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_GET_AUDIO_ATTRIBUTES, COMMAND_GET_VOLUME, COMMAND_GET_DEVICE_VOLUME, COMMAND_SET_VOLUME, COMMAND_SET_DEVICE_VOLUME, COMMAND_ADJUST_DEVICE_VOLUME, COMMAND_SET_VIDEO_SURFACE, COMMAND_GET_TEXT, COMMAND_SET_TRACK_SELECTION_PARAMETERS or COMMAND_GET_TRACK_INFOS. */
|
||||
// emulate headphone buttons
|
||||
// start/pause: adb shell input keyevent 85
|
||||
// next: adb shell input keyevent 87
|
||||
// prev: adb shell input keyevent 88
|
||||
when (playerCommand) {
|
||||
Player.COMMAND_SEEK_TO_NEXT -> {
|
||||
player.addMediaItem(
|
||||
CollectionHelper.getNextMediaItem(
|
||||
this@PlayerService,
|
||||
collection,
|
||||
player.currentMediaItem?.mediaId ?: String()
|
||||
)
|
||||
)
|
||||
player.prepare()
|
||||
player.play()
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS -> {
|
||||
player.addMediaItem(
|
||||
CollectionHelper.getPreviousMediaItem(
|
||||
this@PlayerService,
|
||||
collection,
|
||||
player.currentMediaItem?.mediaId ?: String()
|
||||
)
|
||||
)
|
||||
player.prepare()
|
||||
player.play()
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
Player.COMMAND_PREPARE -> {
|
||||
return if (playLastStation) {
|
||||
// special case: system requested media resumption (see also onGetLibraryRoot)
|
||||
player.addMediaItem(CollectionHelper.getRecent(this@PlayerService, collection))
|
||||
player.prepare()
|
||||
playLastStation = false
|
||||
SessionResult.RESULT_SUCCESS
|
||||
} else {
|
||||
super.onPlayerCommandRequest(session, controller, playerCommand)
|
||||
}
|
||||
}
|
||||
Player.COMMAND_PLAY_PAUSE -> {
|
||||
return if (player.isPlaying) {
|
||||
super.onPlayerCommandRequest(session, controller, playerCommand)
|
||||
} else {
|
||||
// seek to the start of the "live window"
|
||||
player.seekTo(0)
|
||||
SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
}
|
||||
// Player.COMMAND_PLAY_PAUSE -> {
|
||||
// // override pause with stop, to prevent unnecessary buffering
|
||||
// if (player.isPlaying) {
|
||||
// player.stop()
|
||||
// return SessionResult.RESULT_INFO_SKIPPED
|
||||
// } else {
|
||||
// return super.onPlayerCommandRequest(session, controller, playerCommand)
|
||||
// }
|
||||
// }
|
||||
else -> {
|
||||
return super.onPlayerCommandRequest(session, controller, playerCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* NotificationProvider to customize Notification actions
|
||||
*/
|
||||
private inner class CustomNotificationProvider :
|
||||
DefaultMediaNotificationProvider(this@PlayerService) {
|
||||
override fun getMediaButtons(
|
||||
session: MediaSession,
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
showPauseButton: Boolean
|
||||
): ImmutableList<CommandButton> {
|
||||
val seekToPreviousCommandButton = CommandButton.Builder().apply {
|
||||
setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
setIconResId(R.drawable.ic_notification_skip_to_previous_36dp)
|
||||
setEnabled(true)
|
||||
}.build()
|
||||
val playCommandButton = CommandButton.Builder().apply {
|
||||
setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
|
||||
setIconResId(if (player.isPlaying) R.drawable.ic_notification_stop_36dp else R.drawable.ic_notification_play_36dp)
|
||||
setEnabled(true)
|
||||
}.build()
|
||||
val seekToNextCommandButton = CommandButton.Builder().apply {
|
||||
setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
|
||||
setIconResId(R.drawable.ic_notification_skip_to_next_36dp)
|
||||
setEnabled(true)
|
||||
}.build()
|
||||
val commandButtons: MutableList<CommandButton> = mutableListOf(
|
||||
seekToPreviousCommandButton,
|
||||
playCommandButton,
|
||||
seekToNextCommandButton
|
||||
)
|
||||
return ImmutableList.copyOf(commandButtons)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Player.Listener: Called when one or more player states changed.
|
||||
*/
|
||||
private var playerListener: Player.Listener = object : Player.Listener {
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
// store state of playback
|
||||
val currentMediaId: String = player.currentMediaItem?.mediaId ?: String()
|
||||
PreferencesHelper.saveIsPlaying(isPlaying)
|
||||
PreferencesHelper.saveCurrentStationId(currentMediaId)
|
||||
// reset restart counter
|
||||
playbackRestartCounter = 0
|
||||
// save collection and player state
|
||||
|
||||
collection = CollectionHelper.savePlaybackState(
|
||||
this@PlayerService,
|
||||
collection,
|
||||
currentMediaId,
|
||||
isPlaying
|
||||
)
|
||||
//updatePlayerState(station, playbackState)
|
||||
|
||||
if (isPlaying) {
|
||||
// playback is active
|
||||
} else {
|
||||
// cancel sleep timer
|
||||
cancelSleepTimer()
|
||||
// reset metadata
|
||||
updateMetadata()
|
||||
|
||||
// playback is not active
|
||||
// Not playing because playback is paused, ended, suppressed, or the player
|
||||
// is buffering, stopped or failed. Check player.getPlayWhenReady,
|
||||
// player.getPlaybackState, player.getPlaybackSuppressionReason and
|
||||
// player.getPlaybackError for details.
|
||||
when (player.playbackState) {
|
||||
// player is able to immediately play from its current position
|
||||
Player.STATE_READY -> {
|
||||
// todo
|
||||
}
|
||||
// buffering - data needs to be loaded
|
||||
Player.STATE_BUFFERING -> {
|
||||
// todo
|
||||
}
|
||||
// player finished playing all media
|
||||
Player.STATE_ENDED -> {
|
||||
// todo
|
||||
}
|
||||
// initial state or player is stopped or playback failed
|
||||
Player.STATE_IDLE -> {
|
||||
// todo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
if (!playWhenReady) {
|
||||
when (reason) {
|
||||
Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> {
|
||||
// playback reached end: stop / end playback
|
||||
}
|
||||
else -> {
|
||||
// playback has been paused by user or OS: update media session and save state
|
||||
// PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST or
|
||||
// PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS or
|
||||
// PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY or
|
||||
// PLAY_WHEN_READY_CHANGE_REASON_REMOTE
|
||||
// handlePlaybackChange(PlaybackStateCompat.STATE_PAUSED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
super.onPlayerError(error)
|
||||
Log.d(TAG, "PlayerError occurred: ${error.errorCodeName}")
|
||||
// todo: test if playback needs to be restarted
|
||||
}
|
||||
|
||||
|
||||
override fun onMetadata(metadata: Metadata) {
|
||||
super.onMetadata(metadata)
|
||||
updateMetadata(AudioHelper.getMetadataString(metadata))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Custom LoadErrorHandlingPolicy that network drop outs
|
||||
*/
|
||||
private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() {
|
||||
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
|
||||
// try to reconnect every 5 seconds - up to 20 times
|
||||
if (loadErrorInfo.errorCount <= Keys.DEFAULT_MAX_RECONNECTION_COUNT && loadErrorInfo.exception is HttpDataSource.HttpDataSourceException) {
|
||||
return Keys.RECONNECTION_WAIT_INTERVAL
|
||||
// } else {
|
||||
// CoroutineScope(Main).launch {
|
||||
// player.stop()
|
||||
// }
|
||||
}
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
|
||||
override fun getMinimumLoadableRetryCount(dataType: Int): Int {
|
||||
return Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Custom receiver that handles Keys.ACTION_COLLECTION_CHANGED
|
||||
*/
|
||||
private val collectionChangedReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.hasExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE)) {
|
||||
val date = Date(intent.getLongExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE, 0L))
|
||||
|
||||
if (date.after(collection.modificationDate)) {
|
||||
Log.v(TAG, "PlayerService - reload collection after broadcast received.")
|
||||
loadCollection(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Defines the listener for changes in shared preferences
|
||||
*/
|
||||
private val sharedPreferenceChangeListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_LARGE_BUFFER_SIZE -> {
|
||||
bufferSizeMultiplier = PreferencesHelper.loadBufferSizeMultiplier()
|
||||
if (!player.isPlaying && !player.isLoading) {
|
||||
initializePlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Custom AnalyticsListener that enables AudioFX equalizer integration
|
||||
*/
|
||||
private val analyticsListener = object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int
|
||||
) {
|
||||
super.onAudioSessionIdChanged(eventTime, audioSessionId)
|
||||
// integrate with system equalizer (AudioFX)
|
||||
val intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
||||
intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
|
||||
intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
sendBroadcast(intent)
|
||||
// note: remember to broadcast AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, when not needed anymore
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/src/main/java/com/michatec/radio/Radio.kt
Normal file
45
app/src/main/java/com/michatec/radio/Radio.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Radio.kt
|
||||
* Implements the Radio class
|
||||
* Radio is the base Application class that sets up day and night theme
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import android.app.Application
|
||||
import com.michatec.radio.helpers.AppThemeHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper.initPreferences
|
||||
|
||||
|
||||
/**
|
||||
* Radio.class
|
||||
*/
|
||||
class Radio : Application() {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = Radio::class.java.simpleName
|
||||
|
||||
/* Implements onCreate */
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initPreferences()
|
||||
// set Dark / Light theme state
|
||||
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
|
||||
}
|
||||
|
||||
|
||||
/* Implements onTerminate */
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
}
|
||||
|
||||
}
|
||||
574
app/src/main/java/com/michatec/radio/SettingsFragment.kt
Normal file
574
app/src/main/java/com/michatec/radio/SettingsFragment.kt
Normal file
@@ -0,0 +1,574 @@
|
||||
/*
|
||||
* SettingsFragment.kt
|
||||
* Implements the SettingsFragment fragment
|
||||
* A SettingsFragment displays the user accessible settings of the app
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.michatec.radio.dialogs.ErrorDialog
|
||||
import com.michatec.radio.dialogs.YesNoDialog
|
||||
import com.michatec.radio.helpers.*
|
||||
import com.michatec.radio.helpers.AppThemeHelper.getColor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* SettingsFragment class
|
||||
*/
|
||||
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = SettingsFragment::class.java.simpleName
|
||||
|
||||
/* Overrides onViewCreated from PreferenceFragmentCompat */
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// show action bar
|
||||
(activity as AppCompatActivity).supportActionBar?.show()
|
||||
(activity as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.fragment_settings_title)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// above Android Oreo
|
||||
(activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground)
|
||||
} else {
|
||||
val nightMode = AppCompatDelegate.getDefaultNightMode()
|
||||
if (nightMode == AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
// night mode is active, set navigation bar color to a suitable color for night mode
|
||||
(activity as AppCompatActivity).window.navigationBarColor = getColor(requireContext(), android.R.attr.colorBackground)
|
||||
} else {
|
||||
// night mode is not active, set navigation bar color to a suitable color for day mode
|
||||
(activity as AppCompatActivity).window.navigationBarColor = ContextCompat.getColor(requireContext(), android.R.color.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Overrides onCreatePreferences from PreferenceFragmentCompat */
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
||||
val context = preferenceManager.context
|
||||
val screen = preferenceManager.createPreferenceScreen(context)
|
||||
|
||||
// set up "App Theme" preference
|
||||
val preferenceThemeSelection = ListPreference(activity as Context)
|
||||
preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title)
|
||||
preferenceThemeSelection.setIcon(R.drawable.ic_brush_24dp)
|
||||
preferenceThemeSelection.key = Keys.PREF_THEME_SELECTION
|
||||
preferenceThemeSelection.summary = "${getString(R.string.pref_theme_selection_summary)} ${
|
||||
AppThemeHelper.getCurrentTheme(activity as Context)
|
||||
}"
|
||||
preferenceThemeSelection.entries = arrayOf(
|
||||
getString(R.string.pref_theme_selection_mode_device_default),
|
||||
getString(R.string.pref_theme_selection_mode_light),
|
||||
getString(R.string.pref_theme_selection_mode_dark)
|
||||
)
|
||||
preferenceThemeSelection.entryValues = arrayOf(
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM,
|
||||
Keys.STATE_THEME_LIGHT_MODE,
|
||||
Keys.STATE_THEME_DARK_MODE
|
||||
)
|
||||
preferenceThemeSelection.setDefaultValue(Keys.STATE_THEME_FOLLOW_SYSTEM)
|
||||
preferenceThemeSelection.setOnPreferenceChangeListener { preference, newValue ->
|
||||
if (preference is ListPreference) {
|
||||
val index: Int = preference.entryValues.indexOf(newValue)
|
||||
preferenceThemeSelection.summary =
|
||||
"${getString(R.string.pref_theme_selection_summary)} ${preference.entries[index]}"
|
||||
return@setOnPreferenceChangeListener true
|
||||
} else {
|
||||
return@setOnPreferenceChangeListener false
|
||||
}
|
||||
}
|
||||
|
||||
// set up "Update Station Images" preference
|
||||
val preferenceUpdateStationImages = Preference(activity as Context)
|
||||
preferenceUpdateStationImages.title = getString(R.string.pref_update_station_images_title)
|
||||
preferenceUpdateStationImages.setIcon(R.drawable.ic_image_24dp)
|
||||
preferenceUpdateStationImages.summary = getString(R.string.pref_update_station_images_summary)
|
||||
preferenceUpdateStationImages.setOnPreferenceClickListener {
|
||||
// show dialog
|
||||
YesNoDialog(this).show(
|
||||
context = activity as Context,
|
||||
type = Keys.DIALOG_UPDATE_STATION_IMAGES,
|
||||
message = R.string.dialog_yes_no_message_update_station_images,
|
||||
yesButton = R.string.dialog_yes_no_positive_button_update_covers
|
||||
)
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// // set up "Update Stations" preference
|
||||
// val preferenceUpdateCollection: Preference = Preference(activity as Context)
|
||||
// preferenceUpdateCollection.title = getString(R.string.pref_update_collection_title)
|
||||
// preferenceUpdateCollection.setIcon(R.drawable.ic_refresh_24dp)
|
||||
// preferenceUpdateCollection.summary = getString(R.string.pref_update_collection_summary)
|
||||
// preferenceUpdateCollection.setOnPreferenceClickListener {
|
||||
// // show dialog
|
||||
// YesNoDialog(this).show(context = activity as Context, type = Keys.DIALOG_UPDATE_COLLECTION, message = R.string.dialog_yes_no_message_update_collection, yesButton = R.string.dialog_yes_no_positive_button_update_collection)
|
||||
// return@setOnPreferenceClickListener true
|
||||
// }
|
||||
|
||||
|
||||
// set up "M3U Export" preference
|
||||
val preferenceM3uExport = Preference(activity as Context)
|
||||
preferenceM3uExport.title = getString(R.string.pref_m3u_export_title)
|
||||
preferenceM3uExport.setIcon(R.drawable.ic_save_m3u_24dp)
|
||||
preferenceM3uExport.summary = getString(R.string.pref_m3u_export_summary)
|
||||
preferenceM3uExport.setOnPreferenceClickListener {
|
||||
openSaveM3uDialog()
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "PLS Export" preference
|
||||
val preferencePlsExport = Preference(activity as Context)
|
||||
preferencePlsExport.title = getString(R.string.pref_pls_export_title)
|
||||
preferencePlsExport.setIcon(R.drawable.ic_save_pls_24dp)
|
||||
preferencePlsExport.summary = getString(R.string.pref_pls_export_summary)
|
||||
preferencePlsExport.setOnPreferenceClickListener {
|
||||
openSavePlsDialog()
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "Backup Stations" preference
|
||||
val preferenceBackupCollection = Preference(activity as Context)
|
||||
preferenceBackupCollection.title = getString(R.string.pref_station_export_title)
|
||||
preferenceBackupCollection.setIcon(R.drawable.ic_download_24dp)
|
||||
preferenceBackupCollection.summary = getString(R.string.pref_station_export_summary)
|
||||
preferenceBackupCollection.setOnPreferenceClickListener {
|
||||
openBackupCollectionDialog()
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "Restore Stations" preference
|
||||
val preferenceRestoreCollection = Preference(activity as Context)
|
||||
preferenceRestoreCollection.title = getString(R.string.pref_station_restore_title)
|
||||
preferenceRestoreCollection.setIcon(R.drawable.ic_upload_24dp)
|
||||
preferenceRestoreCollection.summary = getString(R.string.pref_station_restore_summary)
|
||||
preferenceRestoreCollection.setOnPreferenceClickListener {
|
||||
openRestoreCollectionDialog()
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "Buffer Size" preference
|
||||
val preferenceBufferSize = SwitchPreferenceCompat(activity as Context)
|
||||
preferenceBufferSize.title = getString(R.string.pref_buffer_size_title)
|
||||
preferenceBufferSize.setIcon(R.drawable.ic_network_check_24dp)
|
||||
preferenceBufferSize.key = Keys.PREF_LARGE_BUFFER_SIZE
|
||||
preferenceBufferSize.summaryOn = getString(R.string.pref_buffer_size_summary_enabled)
|
||||
preferenceBufferSize.summaryOff = getString(R.string.pref_buffer_size_summary_disabled)
|
||||
preferenceBufferSize.setDefaultValue(PreferencesHelper.loadLargeBufferSize())
|
||||
|
||||
|
||||
// set up "Edit Stream Address" preference
|
||||
val preferenceEnableEditingStreamUri = SwitchPreferenceCompat(activity as Context)
|
||||
preferenceEnableEditingStreamUri.title = getString(R.string.pref_edit_station_stream_title)
|
||||
preferenceEnableEditingStreamUri.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceEnableEditingStreamUri.key = Keys.PREF_EDIT_STREAMS_URIS
|
||||
preferenceEnableEditingStreamUri.summaryOn = getString(R.string.pref_edit_station_stream_summary_enabled)
|
||||
preferenceEnableEditingStreamUri.summaryOff = getString(R.string.pref_edit_station_stream_summary_disabled)
|
||||
preferenceEnableEditingStreamUri.setDefaultValue(PreferencesHelper.loadEditStreamUrisEnabled())
|
||||
|
||||
|
||||
// set up "Edit Stations" preference
|
||||
val preferenceEnableEditingGeneral = SwitchPreferenceCompat(activity as Context)
|
||||
preferenceEnableEditingGeneral.title = getString(R.string.pref_edit_station_title)
|
||||
preferenceEnableEditingGeneral.setIcon(R.drawable.ic_edit_24dp)
|
||||
preferenceEnableEditingGeneral.key = Keys.PREF_EDIT_STATIONS
|
||||
preferenceEnableEditingGeneral.summaryOn = getString(R.string.pref_edit_station_summary_enabled)
|
||||
preferenceEnableEditingGeneral.summaryOff = getString(R.string.pref_edit_station_summary_disabled)
|
||||
preferenceEnableEditingGeneral.setDefaultValue(PreferencesHelper.loadEditStationsEnabled())
|
||||
preferenceEnableEditingGeneral.setOnPreferenceChangeListener { _, newValue ->
|
||||
when (newValue) {
|
||||
true -> {
|
||||
preferenceEnableEditingStreamUri.isEnabled = true
|
||||
}
|
||||
false -> {
|
||||
preferenceEnableEditingStreamUri.isEnabled = false
|
||||
preferenceEnableEditingStreamUri.isChecked = false
|
||||
}
|
||||
}
|
||||
return@setOnPreferenceChangeListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "App Version" preference
|
||||
val preferenceAppVersion = Preference(context)
|
||||
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
|
||||
preferenceAppVersion.setIcon(R.drawable.ic_info_24dp)
|
||||
preferenceAppVersion.summary = "${getString(R.string.pref_app_version_summary)} ${BuildConfig.VERSION_NAME} (${getString(R.string.app_version_name)})"
|
||||
preferenceAppVersion.setOnPreferenceClickListener {
|
||||
// copy to clipboard
|
||||
val clip: ClipData = ClipData.newPlainText("simple text", preferenceAppVersion.summary)
|
||||
val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// since API 33 (TIRAMISU) the OS displays its own notification when content is copied to the clipboard
|
||||
Snackbar.make(requireView(), R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set up "GitHub" preference
|
||||
val preferenceGitHub = Preference(context)
|
||||
preferenceGitHub.title = getString(R.string.pref_github_title)
|
||||
preferenceGitHub.setIcon(R.drawable.ic_github_24dp)
|
||||
preferenceGitHub.summary = getString(R.string.pref_github_summary)
|
||||
preferenceGitHub.setOnPreferenceClickListener {
|
||||
// open web browser
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "https://github.com/michatec/Radio".toUri()
|
||||
}
|
||||
startActivity(intent)
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
// set up "License" preference
|
||||
val preferenceLicense = Preference(context)
|
||||
preferenceLicense.title = getString(R.string.pref_license_title)
|
||||
preferenceLicense.setIcon(R.drawable.ic_library_24dp)
|
||||
preferenceLicense.summary = getString(R.string.pref_license_summary)
|
||||
preferenceLicense.setOnPreferenceClickListener {
|
||||
// open web browser
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "https://github.com/michatec/Radio/blob/master/LICENSE.md".toUri()
|
||||
}
|
||||
startActivity(intent)
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
|
||||
// set preference categories
|
||||
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
|
||||
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
|
||||
preferenceCategoryGeneral.contains(preferenceThemeSelection)
|
||||
|
||||
val preferenceCategoryMaintenance = PreferenceCategory(activity as Context)
|
||||
preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title)
|
||||
preferenceCategoryMaintenance.contains(preferenceUpdateStationImages)
|
||||
// preferenceCategoryMaintenance.contains(preferenceUpdateCollection)
|
||||
|
||||
val preferenceCategoryImportExport = PreferenceCategory(activity as Context)
|
||||
preferenceCategoryImportExport.title = getString(R.string.pref_backup_import_export_title)
|
||||
preferenceCategoryImportExport.contains(preferenceM3uExport)
|
||||
preferenceCategoryImportExport.contains(preferencePlsExport)
|
||||
preferenceCategoryImportExport.contains(preferenceBackupCollection)
|
||||
preferenceCategoryImportExport.contains(preferenceRestoreCollection)
|
||||
|
||||
val preferenceCategoryAdvanced = PreferenceCategory(activity as Context)
|
||||
preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title)
|
||||
preferenceCategoryAdvanced.contains(preferenceBufferSize)
|
||||
preferenceCategoryAdvanced.contains(preferenceEnableEditingGeneral)
|
||||
preferenceCategoryAdvanced.contains(preferenceEnableEditingStreamUri)
|
||||
|
||||
val preferenceCategoryLinks = PreferenceCategory(context)
|
||||
preferenceCategoryLinks.title = getString(R.string.pref_links_title)
|
||||
preferenceCategoryLinks.contains(preferenceAppVersion)
|
||||
preferenceCategoryLinks.contains(preferenceGitHub)
|
||||
|
||||
|
||||
// setup preference screen
|
||||
screen.addPreference(preferenceAppVersion)
|
||||
screen.addPreference(preferenceLicense)
|
||||
screen.addPreference(preferenceCategoryGeneral)
|
||||
screen.addPreference(preferenceThemeSelection)
|
||||
screen.addPreference(preferenceCategoryMaintenance)
|
||||
screen.addPreference(preferenceUpdateStationImages)
|
||||
// screen.addPreference(preferenceUpdateCollection)
|
||||
screen.addPreference(preferenceCategoryImportExport)
|
||||
screen.addPreference(preferenceM3uExport)
|
||||
screen.addPreference(preferencePlsExport)
|
||||
screen.addPreference(preferenceBackupCollection)
|
||||
screen.addPreference(preferenceRestoreCollection)
|
||||
screen.addPreference(preferenceCategoryAdvanced)
|
||||
screen.addPreference(preferenceBufferSize)
|
||||
screen.addPreference(preferenceEnableEditingGeneral)
|
||||
screen.addPreference(preferenceEnableEditingStreamUri)
|
||||
screen.addPreference(preferenceCategoryLinks)
|
||||
screen.addPreference(preferenceGitHub)
|
||||
preferenceScreen = screen
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onYesNoDialog from YesNoDialogListener */
|
||||
override fun onYesNoDialog(
|
||||
type: Int,
|
||||
dialogResult: Boolean,
|
||||
payload: Int,
|
||||
payloadString: String
|
||||
) {
|
||||
super.onYesNoDialog(type, dialogResult, payload, payloadString)
|
||||
|
||||
when (type) {
|
||||
Keys.DIALOG_UPDATE_STATION_IMAGES -> {
|
||||
if (dialogResult) {
|
||||
// user tapped: refresh station images
|
||||
updateStationImages()
|
||||
}
|
||||
}
|
||||
|
||||
Keys.DIALOG_UPDATE_COLLECTION -> {
|
||||
if (dialogResult) {
|
||||
// user tapped update collection
|
||||
updateCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Register the ActivityResultLauncher for the save m3u dialog */
|
||||
private val requestSaveM3uLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestSaveM3uResult)
|
||||
|
||||
|
||||
/* Register the ActivityResultLauncher for the save pls dialog */
|
||||
private val requestSavePlsLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestSavePlsResult)
|
||||
|
||||
|
||||
/* Register the ActivityResultLauncher for the backup dialog */
|
||||
private val requestBackupCollectionLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestBackupCollectionResult)
|
||||
|
||||
|
||||
/* Register the ActivityResultLauncher for the restore dialog */
|
||||
private val requestRestoreCollectionLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestRestoreCollectionResult)
|
||||
|
||||
|
||||
/* Pass the activity result for the save m3u dialog */
|
||||
private fun requestSaveM3uResult(result: ActivityResult) {
|
||||
// save M3U file to result file location
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val sourceUri: Uri? = FileHelper.getM3ulUri(activity as Activity)
|
||||
val targetUri: Uri? = result.data?.data
|
||||
if (targetUri != null && sourceUri != null) {
|
||||
// copy file async (= fire & forget - no return value needed)
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.saveCopyOfFileSuspended(activity as Context, sourceUri, targetUri)
|
||||
}
|
||||
Snackbar.make(requireView(), R.string.toastmessage_save_m3u, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
Log.w(TAG, "M3U export failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pass the activity result for the save pls dialog */
|
||||
private fun requestSavePlsResult(result: ActivityResult) {
|
||||
// save PLS file to result file location
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val sourceUri: Uri? = FileHelper.getPlslUri(activity as Activity)
|
||||
val targetUri: Uri? = result.data?.data
|
||||
if (targetUri != null && sourceUri != null) {
|
||||
// copy file async (= fire & forget - no return value needed)
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.saveCopyOfFileSuspended(activity as Context, sourceUri, targetUri)
|
||||
}
|
||||
Snackbar.make(requireView(), R.string.toastmessage_save_pls, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
Log.w(TAG, "PLS export failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pass the activity result for the backup collection dialog */
|
||||
private fun requestBackupCollectionResult(result: ActivityResult) {
|
||||
// save station backup file to result file location
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val targetUri: Uri? = result.data?.data
|
||||
if (targetUri != null) {
|
||||
BackupHelper.backup(requireView(), activity as Context, targetUri)
|
||||
Log.e(TAG, "Backing up to $targetUri")
|
||||
} else {
|
||||
Log.w(TAG, "Station backup failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pass the activity result for the restore collection dialog */
|
||||
private fun requestRestoreCollectionResult(result: ActivityResult) {
|
||||
// save station backup file to result file location
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val sourceUri: Uri? = result.data?.data
|
||||
if (sourceUri != null) {
|
||||
// open and import OPML in player fragment
|
||||
val bundle: Bundle = bundleOf(
|
||||
Keys.ARG_RESTORE_COLLECTION to "$sourceUri"
|
||||
)
|
||||
this.findNavController().navigate(R.id.player_destination, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates collection */
|
||||
private fun updateCollection() {
|
||||
if (NetworkHelper.isConnectedToNetwork(activity as Context)) {
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
R.string.toastmessage_updating_collection,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
// update collection in player screen
|
||||
val bundle: Bundle = bundleOf(Keys.ARG_UPDATE_COLLECTION to true)
|
||||
this.findNavController().navigate(R.id.player_destination, bundle)
|
||||
} else {
|
||||
ErrorDialog().show(
|
||||
activity as Context,
|
||||
R.string.dialog_error_title_no_network,
|
||||
R.string.dialog_error_message_no_network
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates station images */
|
||||
private fun updateStationImages() {
|
||||
if (NetworkHelper.isConnectedToNetwork(activity as Context)) {
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
R.string.toastmessage_updating_station_images,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
// update collection in player screen
|
||||
val bundle: Bundle = bundleOf(
|
||||
Keys.ARG_UPDATE_IMAGES to true
|
||||
)
|
||||
this.findNavController().navigate(R.id.player_destination, bundle)
|
||||
} else {
|
||||
ErrorDialog().show(
|
||||
activity as Context,
|
||||
R.string.dialog_error_title_no_network,
|
||||
R.string.dialog_error_message_no_network
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Opens up a file picker to select the save location */
|
||||
private fun openSaveM3uDialog() {
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = Keys.MIME_TYPE_M3U
|
||||
|
||||
val timeStamp: String
|
||||
val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US)
|
||||
timeStamp = dateFormat.format(Date())
|
||||
|
||||
putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.m3u")
|
||||
}
|
||||
// file gets saved in the ActivityResult
|
||||
try {
|
||||
requestSaveM3uLauncher.launch(intent)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to save M3U.\n$exception")
|
||||
Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Opens up a file picker to select the save location */
|
||||
private fun openSavePlsDialog() {
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = Keys.MIME_TYPE_PLS
|
||||
|
||||
val timeStamp: String
|
||||
val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US)
|
||||
timeStamp = dateFormat.format(Date())
|
||||
|
||||
putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.pls")
|
||||
}
|
||||
// file gets saved in the ActivityResult
|
||||
try {
|
||||
requestSavePlsLauncher.launch(intent)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to save PLS.\n$exception")
|
||||
Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Opens up a file picker to select the backup location */
|
||||
private fun openBackupCollectionDialog() {
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = Keys.MIME_TYPE_ZIP
|
||||
|
||||
val timeStamp: String
|
||||
val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US)
|
||||
timeStamp = dateFormat.format(Date())
|
||||
|
||||
putExtra(Intent.EXTRA_TITLE, "URL_Radio$timeStamp.zip")
|
||||
}
|
||||
// file gets saved in the ActivityResult
|
||||
try {
|
||||
requestBackupCollectionLauncher.launch(intent)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to save M3U.\n$exception")
|
||||
Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Opens up a file picker to select the file containing the collection to be restored */
|
||||
private fun openRestoreCollectionDialog() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, Keys.MIME_TYPES_ZIP)
|
||||
}
|
||||
// file gets saved in the ActivityResult
|
||||
try {
|
||||
requestRestoreCollectionLauncher.launch(intent)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to open file picker for ZIP.\n$exception")
|
||||
// Toast.makeText(activity as Context, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
/*
|
||||
* CollectionAdapter.kt
|
||||
* Implements the CollectionAdapter class
|
||||
* A CollectionAdapter is a custom adapter providing station card views for a RecyclerView
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.collection
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* CollectionAdapter class
|
||||
*/
|
||||
class CollectionAdapter(
|
||||
private val context: Context,
|
||||
private val collectionAdapterListener: CollectionAdapterListener
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), UpdateHelper.UpdateHelperListener {
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var collectionViewModel: CollectionViewModel
|
||||
private var collection: Collection = Collection()
|
||||
private var editStationsEnabled: Boolean = PreferencesHelper.loadEditStationsEnabled()
|
||||
private var editStationStreamsEnabled: Boolean = PreferencesHelper.loadEditStreamUrisEnabled()
|
||||
private var expandedStationUuid: String = PreferencesHelper.loadStationListStreamUuid()
|
||||
private var expandedStationPosition: Int = -1
|
||||
var isExpandedForEdit: Boolean = false
|
||||
|
||||
|
||||
/* Listener Interface */
|
||||
interface CollectionAdapterListener {
|
||||
fun onPlayButtonTapped(stationUuid: String)
|
||||
fun onAddNewButtonTapped()
|
||||
fun onChangeImageButtonTapped(stationUuid: String)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onAttachedToRecyclerView */
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
// create view model and observe changes in collection view model
|
||||
collectionViewModel =
|
||||
ViewModelProvider(context as AppCompatActivity)[CollectionViewModel::class.java]
|
||||
observeCollectionViewModel(context as LifecycleOwner)
|
||||
// start listening for changes in shared preferences
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onDetachedFromRecyclerView */
|
||||
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onDetachedFromRecyclerView(recyclerView)
|
||||
// stop listening for changes in shared preferences
|
||||
PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onCreateViewHolder */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
|
||||
return when (viewType) {
|
||||
Keys.VIEW_TYPE_ADD_NEW -> {
|
||||
// get view, put view into holder and return
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.card_add_new_station, parent, false)
|
||||
AddNewViewHolder(v)
|
||||
}
|
||||
else -> {
|
||||
// get view, put view into holder and return
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.card_station, parent, false)
|
||||
StationViewHolder(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Implement the method to handle item move */
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int) {
|
||||
// Do nothing if in "edit" mode
|
||||
if (isExpandedForEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
val stationList = collection.stations
|
||||
val stationCount = stationList.size
|
||||
|
||||
if (fromPosition !in 0 until stationCount || toPosition !in 0 until stationCount) {
|
||||
return
|
||||
}
|
||||
|
||||
val fromStation = stationList[fromPosition]
|
||||
val toStation = stationList[toPosition]
|
||||
|
||||
if (fromStation.starred != toStation.starred) {
|
||||
// Prevent moving a starred item into non-starred area or vice versa
|
||||
return
|
||||
}
|
||||
|
||||
// Move within the same group (either starred or non-starred)
|
||||
Collections.swap(stationList, fromPosition, toPosition)
|
||||
|
||||
// Update the value of expandedStationPosition if necessary
|
||||
expandedStationPosition = if (fromPosition == expandedStationPosition) toPosition else expandedStationPosition
|
||||
|
||||
// Notify the adapter about the item move
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
}
|
||||
|
||||
|
||||
/* Implement the method to handle item dismissal */
|
||||
fun onItemDismiss(position: Int) {
|
||||
// Remove the item at the given position from your data collection
|
||||
collection.stations.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
|
||||
|
||||
/* Method for saving the collection after the drag-and-drop operation */
|
||||
fun saveCollectionAfterDragDrop() {
|
||||
// Save the collection after the dragging is completed
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onBindViewHolder */
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
// CASE ADD NEW CARD
|
||||
is AddNewViewHolder -> {
|
||||
// get reference to StationViewHolder
|
||||
val addNewViewHolder: AddNewViewHolder = holder
|
||||
addNewViewHolder.addNewStationView.setOnClickListener {
|
||||
// show the add station dialog
|
||||
collectionAdapterListener.onAddNewButtonTapped()
|
||||
}
|
||||
addNewViewHolder.settingsButtonView.setOnClickListener {
|
||||
it.findNavController().navigate(R.id.settings_destination)
|
||||
}
|
||||
}
|
||||
// CASE STATION CARD
|
||||
is StationViewHolder -> {
|
||||
// get station from position
|
||||
val station: Station = collection.stations[position]
|
||||
|
||||
// get reference to StationViewHolder
|
||||
val stationViewHolder: StationViewHolder = holder
|
||||
|
||||
// set up station views
|
||||
setStarredIcon(stationViewHolder, station)
|
||||
setStationName(stationViewHolder, station)
|
||||
setStationImage(stationViewHolder, station)
|
||||
setStationButtons(stationViewHolder, station)
|
||||
setEditViews(stationViewHolder, station)
|
||||
|
||||
// show / hide edit views
|
||||
when (expandedStationPosition) {
|
||||
// show edit views
|
||||
position -> {
|
||||
stationViewHolder.stationNameView.isVisible = false
|
||||
stationViewHolder.playButtonView.isGone = true
|
||||
stationViewHolder.stationStarredView.isGone = true
|
||||
stationViewHolder.editViews.isVisible = true
|
||||
if (editStationStreamsEnabled) {
|
||||
stationViewHolder.stationUriEditView.isVisible = true
|
||||
stationViewHolder.stationUriEditView.imeOptions =
|
||||
EditorInfo.IME_ACTION_DONE
|
||||
} else {
|
||||
stationViewHolder.stationUriEditView.isGone = true
|
||||
stationViewHolder.stationNameEditView.imeOptions =
|
||||
EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
}
|
||||
// hide edit views
|
||||
else -> {
|
||||
stationViewHolder.stationNameView.isVisible = true
|
||||
//stationViewHolder.playButtonView.isVisible = true
|
||||
stationViewHolder.stationStarredView.isVisible = station.starred
|
||||
stationViewHolder.editViews.isGone = true
|
||||
stationViewHolder.stationUriEditView.isGone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onStationUpdated from UpdateHelperListener */
|
||||
override fun onStationUpdated(
|
||||
collection: Collection,
|
||||
positionPriorUpdate: Int,
|
||||
positionAfterUpdate: Int
|
||||
) {
|
||||
// check if position has changed after update and move stations around if necessary
|
||||
if (positionPriorUpdate != positionAfterUpdate && positionPriorUpdate != -1 && positionAfterUpdate != -1) {
|
||||
notifyItemMoved(positionPriorUpdate, positionAfterUpdate)
|
||||
notifyItemChanged(positionPriorUpdate)
|
||||
}
|
||||
// update station (e.g. name)
|
||||
notifyItemChanged(positionAfterUpdate)
|
||||
}
|
||||
|
||||
|
||||
/* Sets the station name view */
|
||||
private fun setStationName(stationViewHolder: StationViewHolder, station: Station) {
|
||||
stationViewHolder.stationNameView.text = station.name
|
||||
}
|
||||
|
||||
|
||||
/* Sets the edit views */
|
||||
private fun setEditViews(stationViewHolder: StationViewHolder, station: Station) {
|
||||
stationViewHolder.stationNameEditView.setText(station.name, TextView.BufferType.EDITABLE)
|
||||
stationViewHolder.stationUriEditView.setText(
|
||||
station.getStreamUri(),
|
||||
TextView.BufferType.EDITABLE
|
||||
)
|
||||
stationViewHolder.stationUriEditView.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
handleStationUriInput(stationViewHolder, s, station.getStreamUri())
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
stationViewHolder.cancelButton.setOnClickListener {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
|
||||
}
|
||||
stationViewHolder.saveButton.setOnClickListener {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
saveStation(
|
||||
station,
|
||||
position,
|
||||
stationViewHolder.stationNameEditView.text.toString(),
|
||||
stationViewHolder.stationUriEditView.text.toString()
|
||||
)
|
||||
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
|
||||
}
|
||||
stationViewHolder.placeOnHomeScreenButton.setOnClickListener {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
ShortcutHelper.placeShortcut(context, station)
|
||||
toggleEditViews(position, station.uuid)
|
||||
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
|
||||
}
|
||||
stationViewHolder.stationImageChangeView.setOnClickListener {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
collectionAdapterListener.onChangeImageButtonTapped(station.uuid)
|
||||
stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Shows / hides the edit view for a station */
|
||||
private fun toggleEditViews(position: Int, stationUuid: String) {
|
||||
when (stationUuid) {
|
||||
// CASE: this station's edit view is already expanded
|
||||
expandedStationUuid -> {
|
||||
isExpandedForEdit = false
|
||||
// reset currently expanded info (both uuid and position)
|
||||
saveStationListExpandedState()
|
||||
// update station view
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
// CASE: this station's edit view is not yet expanded
|
||||
else -> {
|
||||
isExpandedForEdit = true
|
||||
// remember previously expanded position
|
||||
val previousExpandedStationPosition: Int = expandedStationPosition
|
||||
// if station was expanded - collapse it
|
||||
if (previousExpandedStationPosition > -1 && previousExpandedStationPosition < collection.stations.size)
|
||||
notifyItemChanged(previousExpandedStationPosition)
|
||||
// store current station as the expanded one
|
||||
saveStationListExpandedState(position, stationUuid)
|
||||
// update station view
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Toggles the starred icon */
|
||||
private fun setStarredIcon(stationViewHolder: StationViewHolder, station: Station) {
|
||||
when (station.starred) {
|
||||
true -> {
|
||||
if (station.imageColor != -1) {
|
||||
// stationViewHolder.stationCardView.setCardBackgroundColor(station.imageColor)
|
||||
stationViewHolder.stationStarredView.setColorFilter(station.imageColor)
|
||||
}
|
||||
stationViewHolder.stationStarredView.isVisible = true
|
||||
}
|
||||
false -> stationViewHolder.stationStarredView.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sets the station image view */
|
||||
private fun setStationImage(stationViewHolder: StationViewHolder, station: Station) {
|
||||
if (station.imageColor != -1) {
|
||||
stationViewHolder.stationImageView.setBackgroundColor(station.imageColor)
|
||||
}
|
||||
stationViewHolder.stationImageView.setImageBitmap(
|
||||
ImageHelper.getStationImage(
|
||||
context,
|
||||
station.smallImage
|
||||
)
|
||||
)
|
||||
stationViewHolder.stationImageView.contentDescription =
|
||||
"${context.getString(R.string.descr_player_station_image)}: ${station.name}"
|
||||
}
|
||||
|
||||
|
||||
/* Sets up a station's play and edit buttons */
|
||||
private fun setStationButtons(stationViewHolder: StationViewHolder, station: Station) {
|
||||
when (station.isPlaying) {
|
||||
true -> stationViewHolder.playButtonView.visibility = View.VISIBLE
|
||||
false -> stationViewHolder.playButtonView.visibility = View.INVISIBLE
|
||||
}
|
||||
stationViewHolder.stationCardView.setOnClickListener {
|
||||
collectionAdapterListener.onPlayButtonTapped(station.uuid)
|
||||
}
|
||||
stationViewHolder.playButtonView.setOnClickListener {
|
||||
collectionAdapterListener.onPlayButtonTapped(station.uuid)
|
||||
}
|
||||
stationViewHolder.stationNameView.setOnClickListener {
|
||||
collectionAdapterListener.onPlayButtonTapped(station.uuid)
|
||||
}
|
||||
stationViewHolder.stationStarredView.setOnClickListener {
|
||||
collectionAdapterListener.onPlayButtonTapped(station.uuid)
|
||||
}
|
||||
stationViewHolder.stationImageView.setOnClickListener {
|
||||
collectionAdapterListener.onPlayButtonTapped(station.uuid)
|
||||
}
|
||||
stationViewHolder.playButtonView.setOnLongClickListener {
|
||||
if (editStationsEnabled) {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
return@setOnLongClickListener true
|
||||
} else {
|
||||
return@setOnLongClickListener false
|
||||
}
|
||||
}
|
||||
stationViewHolder.stationNameView.setOnLongClickListener {
|
||||
if (editStationsEnabled) {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
return@setOnLongClickListener true
|
||||
} else {
|
||||
return@setOnLongClickListener false
|
||||
}
|
||||
}
|
||||
stationViewHolder.stationStarredView.setOnLongClickListener {
|
||||
if (editStationsEnabled) {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
return@setOnLongClickListener true
|
||||
} else {
|
||||
return@setOnLongClickListener false
|
||||
}
|
||||
}
|
||||
stationViewHolder.stationImageView.setOnLongClickListener {
|
||||
if (editStationsEnabled) {
|
||||
val position: Int = stationViewHolder.adapterPosition
|
||||
toggleEditViews(position, station.uuid)
|
||||
return@setOnLongClickListener true
|
||||
} else {
|
||||
return@setOnLongClickListener false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Checks if stream uri input is valid */
|
||||
private fun handleStationUriInput(
|
||||
stationViewHolder: StationViewHolder,
|
||||
s: Editable?,
|
||||
streamUri: String
|
||||
) {
|
||||
if (editStationStreamsEnabled) {
|
||||
val input: String = s.toString()
|
||||
if (input == streamUri) {
|
||||
// enable save button
|
||||
stationViewHolder.saveButton.isEnabled = true
|
||||
} else {
|
||||
// 1. disable save button
|
||||
stationViewHolder.saveButton.isEnabled = false
|
||||
// 2. check for valid station uri - and re-enable button
|
||||
if (input.startsWith("http")) {
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) {
|
||||
NetworkHelper.detectContentTypeSuspended(input)
|
||||
}
|
||||
// wait for result
|
||||
val contentType: String =
|
||||
deferred.await().type.lowercase(Locale.getDefault())
|
||||
// CASE: stream address detected
|
||||
if (Keys.MIME_TYPES_MPEG.contains(contentType) or
|
||||
Keys.MIME_TYPES_OGG.contains(contentType) or
|
||||
Keys.MIME_TYPES_AAC.contains(contentType) or
|
||||
Keys.MIME_TYPES_HLS.contains(contentType)
|
||||
) {
|
||||
// re-enable save button
|
||||
withContext(Main) {
|
||||
stationViewHolder.saveButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onBindViewHolder */
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
// call regular onBindViewHolder method
|
||||
onBindViewHolder(holder, position)
|
||||
|
||||
} else if (holder is StationViewHolder) {
|
||||
// get station from position
|
||||
collection.stations[holder.getAdapterPosition()]
|
||||
|
||||
for (data in payloads) {
|
||||
when (data as Int) {
|
||||
Keys.HOLDER_UPDATE_COVER -> {
|
||||
// todo implement
|
||||
}
|
||||
Keys.HOLDER_UPDATE_NAME -> {
|
||||
// todo implement
|
||||
}
|
||||
Keys.HOLDER_UPDATE_PLAYBACK_STATE -> {
|
||||
// todo implement
|
||||
}
|
||||
Keys.HOLDER_UPDATE_PLAYBACK_PROGRESS -> {
|
||||
// todo implement
|
||||
}
|
||||
Keys.HOLDER_UPDATE_DOWNLOAD_STATE -> {
|
||||
// todo implement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides getItemViewType */
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (isPositionFooter(position)) {
|
||||
true -> Keys.VIEW_TYPE_ADD_NEW
|
||||
false -> Keys.VIEW_TYPE_STATION
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides getItemCount */
|
||||
override fun getItemCount(): Int {
|
||||
// +1 ==> the add station card
|
||||
return collection.stations.size + 1
|
||||
}
|
||||
|
||||
|
||||
/* Removes a station from collection */
|
||||
fun removeStation(context: Context, position: Int) {
|
||||
val newCollection = collection.deepCopy()
|
||||
// delete images assets
|
||||
CollectionHelper.deleteStationImages(context, newCollection.stations[position])
|
||||
// remove station from collection
|
||||
newCollection.stations.removeAt(position)
|
||||
collection = newCollection
|
||||
// update list
|
||||
notifyItemRemoved(position)
|
||||
// save collection and broadcast changes
|
||||
CollectionHelper.saveCollection(context, newCollection)
|
||||
}
|
||||
|
||||
|
||||
/* Toggles starred status of a station */
|
||||
fun toggleStarredStation(context: Context, position: Int) {
|
||||
// update view (reset "swipe" state of station card)
|
||||
notifyItemChanged(position)
|
||||
// mark starred
|
||||
val stationUuid: String = collection.stations[position].uuid
|
||||
collection.stations[position].apply { starred = !starred }
|
||||
// sort collection
|
||||
collection = CollectionHelper.sortCollection(collection)
|
||||
// update list
|
||||
notifyItemMoved(position, CollectionHelper.getStationPosition(collection, stationUuid))
|
||||
// save collection and broadcast changes
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
|
||||
/* Saves edited station */
|
||||
private fun saveStation(
|
||||
station: Station,
|
||||
position: Int,
|
||||
stationName: String,
|
||||
streamUri: String
|
||||
) {
|
||||
// update station name and stream uri
|
||||
collection.stations.forEach {
|
||||
if (it.uuid == station.uuid) {
|
||||
if (stationName.isNotEmpty()) {
|
||||
it.name = stationName
|
||||
it.nameManuallySet = true
|
||||
}
|
||||
if (streamUri.isNotEmpty()) {
|
||||
it.streamUris[0] = streamUri
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort and save collection
|
||||
collection = CollectionHelper.sortCollection(collection)
|
||||
// update list
|
||||
val newPosition: Int = CollectionHelper.getStationPosition(collection, station.uuid)
|
||||
if (position != newPosition && newPosition != -1) {
|
||||
notifyItemMoved(position, newPosition)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
// save collection and broadcast changes
|
||||
CollectionHelper.saveCollection(context, collection)
|
||||
}
|
||||
|
||||
|
||||
// /* Initiates update of a station's information */ // todo move to CollectionHelper
|
||||
// private fun updateStation(context: Context, station: Station) {
|
||||
// if (station.radioBrowserStationUuid.isNotEmpty()) {
|
||||
// // get updated station from radio browser - results are handled by onRadioBrowserSearchResults
|
||||
// val radioBrowserSearch: RadioBrowserSearch = RadioBrowserSearch(context, this)
|
||||
// radioBrowserSearch.searchStation(context, station.radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID)
|
||||
// } else if (station.remoteStationLocation.isNotEmpty()) {
|
||||
// // download playlist // todo check content type detection is necessary here
|
||||
// DownloadHelper.downloadPlaylists(context, arrayOf(station.remoteStationLocation))
|
||||
// } else {
|
||||
// Log.w(TAG, "Unable to update station: ${station.name}.")
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
/* Determines if position is last */
|
||||
private fun isPositionFooter(position: Int): Boolean {
|
||||
return position == collection.stations.size
|
||||
}
|
||||
|
||||
|
||||
/* Updates the station list - redraws the views with changed content */
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun updateRecyclerView(oldCollection: Collection, newCollection: Collection) {
|
||||
collection = newCollection
|
||||
if (oldCollection.stations.size == 0 && newCollection.stations.size > 0) {
|
||||
// data set has been initialized - redraw the whole list
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
// calculate differences between current collection and new collection - and inform this adapter about the changes
|
||||
val diffResult =
|
||||
DiffUtil.calculateDiff(CollectionDiffCallback(oldCollection, newCollection), true)
|
||||
diffResult.dispatchUpdatesTo(this@CollectionAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates and saves state of expanded station edit view in list */
|
||||
private fun saveStationListExpandedState(
|
||||
position: Int = -1,
|
||||
stationStreamUri: String = String()
|
||||
) {
|
||||
expandedStationUuid = stationStreamUri
|
||||
expandedStationPosition = position
|
||||
PreferencesHelper.saveStationListStreamUuid(expandedStationUuid)
|
||||
}
|
||||
|
||||
|
||||
/* Observe view model of station collection*/
|
||||
private fun observeCollectionViewModel(owner: LifecycleOwner) {
|
||||
collectionViewModel.collectionLiveData.observe(owner) { newCollection ->
|
||||
updateRecyclerView(collection, newCollection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Defines the listener for changes in shared preferences
|
||||
*/
|
||||
private val sharedPreferenceChangeListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
Keys.PREF_EDIT_STATIONS -> editStationsEnabled =
|
||||
PreferencesHelper.loadEditStationsEnabled()
|
||||
Keys.PREF_EDIT_STREAMS_URIS -> editStationStreamsEnabled =
|
||||
PreferencesHelper.loadEditStreamUrisEnabled()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of declaration
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: ViewHolder for the Add New Station action
|
||||
*/
|
||||
private inner class AddNewViewHolder(listItemAddNewLayout: View) :
|
||||
RecyclerView.ViewHolder(listItemAddNewLayout) {
|
||||
val addNewStationView: ExtendedFloatingActionButton =
|
||||
listItemAddNewLayout.findViewById(R.id.card_add_new_station)
|
||||
val settingsButtonView: ExtendedFloatingActionButton =
|
||||
listItemAddNewLayout.findViewById(R.id.card_settings)
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: ViewHolder for a station
|
||||
*/
|
||||
private inner class StationViewHolder(stationCardLayout: View) :
|
||||
RecyclerView.ViewHolder(stationCardLayout) {
|
||||
val stationCardView: CardView = stationCardLayout.findViewById(R.id.station_card)
|
||||
val stationImageView: ImageView = stationCardLayout.findViewById(R.id.station_icon)
|
||||
val stationNameView: TextView = stationCardLayout.findViewById(R.id.station_name)
|
||||
val stationStarredView: ImageView = stationCardLayout.findViewById(R.id.starred_icon)
|
||||
|
||||
// val menuButtonView: ImageView = stationCardLayout.findViewById(R.id.menu_button)
|
||||
val playButtonView: ImageView = stationCardLayout.findViewById(R.id.playback_button)
|
||||
val editViews: Group = stationCardLayout.findViewById(R.id.default_edit_views)
|
||||
val stationImageChangeView: ImageView =
|
||||
stationCardLayout.findViewById(R.id.change_image_view)
|
||||
val stationNameEditView: TextInputEditText =
|
||||
stationCardLayout.findViewById(R.id.edit_station_name)
|
||||
val stationUriEditView: TextInputEditText =
|
||||
stationCardLayout.findViewById(R.id.edit_stream_uri)
|
||||
val placeOnHomeScreenButton: MaterialButton =
|
||||
stationCardLayout.findViewById(R.id.place_on_home_screen_button)
|
||||
val cancelButton: MaterialButton = stationCardLayout.findViewById(R.id.cancel_button)
|
||||
val saveButton: MaterialButton = stationCardLayout.findViewById(R.id.save_button)
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: DiffUtil.Callback that determines changes in data - improves list performance
|
||||
*/
|
||||
private inner class CollectionDiffCallback(
|
||||
val oldCollection: Collection,
|
||||
val newCollection: Collection
|
||||
) : DiffUtil.Callback() {
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldStation: Station = oldCollection.stations[oldItemPosition]
|
||||
val newStation: Station = newCollection.stations[newItemPosition]
|
||||
return oldStation.uuid == newStation.uuid
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return oldCollection.stations.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return newCollection.stations.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldStation: Station = oldCollection.stations[oldItemPosition]
|
||||
val newStation: Station = newCollection.stations[newItemPosition]
|
||||
|
||||
// compare relevant contents of a station
|
||||
if (oldStation.isPlaying != newStation.isPlaying) return false
|
||||
if (oldStation.uuid != newStation.uuid) return false
|
||||
if (oldStation.starred != newStation.starred) return false
|
||||
if (oldStation.name != newStation.name) return false
|
||||
if (oldStation.stream != newStation.stream) return false
|
||||
if (oldStation.remoteImageLocation != newStation.remoteImageLocation) return false
|
||||
if (oldStation.remoteStationLocation != newStation.remoteStationLocation) return false
|
||||
if (!oldStation.streamUris.containsAll(newStation.streamUris)) return false
|
||||
if (oldStation.imageColor != newStation.imageColor) return false
|
||||
if (FileHelper.getFileSize(context, oldStation.image.toUri()) != FileHelper.getFileSize(
|
||||
context,
|
||||
newStation.image.toUri()
|
||||
)
|
||||
) return false
|
||||
if (FileHelper.getFileSize(
|
||||
context,
|
||||
oldStation.smallImage.toUri()
|
||||
) != FileHelper.getFileSize(context, newStation.smallImage.toUri())
|
||||
) return false
|
||||
|
||||
// none of the above -> contents are the same
|
||||
return true
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* CollectionViewModel.kt
|
||||
* Implements the CollectionViewModel class
|
||||
* A CollectionViewModel stores the collection of stations as live data
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.collection
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.helpers.FileHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* CollectionViewModel.class
|
||||
*/
|
||||
class CollectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = CollectionViewModel::class.java.simpleName
|
||||
|
||||
/* Main class variables */
|
||||
val collectionLiveData: MutableLiveData<Collection> = MutableLiveData<Collection>()
|
||||
val collectionSizeLiveData: MutableLiveData<Int> = MutableLiveData<Int>()
|
||||
private var modificationDateViewModel: Date = Date()
|
||||
private var collectionChangedReceiver: BroadcastReceiver
|
||||
|
||||
|
||||
/* Init constructor */
|
||||
init {
|
||||
// load collection
|
||||
loadCollection()
|
||||
// create and register collection changed receiver
|
||||
collectionChangedReceiver = createCollectionChangedReceiver()
|
||||
LocalBroadcastManager.getInstance(application).registerReceiver(
|
||||
collectionChangedReceiver,
|
||||
IntentFilter(Keys.ACTION_COLLECTION_CHANGED)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onCleared */
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
LocalBroadcastManager.getInstance(getApplication())
|
||||
.unregisterReceiver(collectionChangedReceiver)
|
||||
}
|
||||
|
||||
|
||||
/* Creates the collectionChangedReceiver - handles Keys.ACTION_COLLECTION_CHANGED */
|
||||
private fun createCollectionChangedReceiver(): BroadcastReceiver {
|
||||
return object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.hasExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE)) {
|
||||
val date =
|
||||
Date(intent.getLongExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE, 0L))
|
||||
// check if reload is necessary
|
||||
if (date.after(modificationDateViewModel)) {
|
||||
Log.v(
|
||||
TAG,
|
||||
"CollectionViewModel - reload collection after broadcast received."
|
||||
)
|
||||
loadCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads collection of radio stations from storage using GSON */
|
||||
private fun loadCollection() {
|
||||
Log.v(TAG, "Loading collection of stations from storage")
|
||||
viewModelScope.launch {
|
||||
// load collection on background thread
|
||||
val collection: Collection = FileHelper.readCollectionSuspended(getApplication())
|
||||
// get updated modification date
|
||||
modificationDateViewModel = collection.modificationDate
|
||||
// update collection view model
|
||||
collectionLiveData.value = collection
|
||||
// update collection sie
|
||||
collectionSizeLiveData.value = collection.stations.size
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
58
app/src/main/java/com/michatec/radio/core/Collection.kt
Normal file
58
app/src/main/java/com/michatec/radio/core/Collection.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Collection.kt
|
||||
* Implements the Collection class
|
||||
* A Collection object holds a list of radio stations
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
package com.michatec.radio.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.michatec.radio.Keys
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* Collection class
|
||||
*/
|
||||
@Keep
|
||||
@Parcelize
|
||||
data class Collection(
|
||||
@Expose val version: Int = Keys.CURRENT_COLLECTION_CLASS_VERSION_NUMBER,
|
||||
@Expose var stations: MutableList<Station> = mutableListOf(),
|
||||
@Expose var modificationDate: Date = Date()
|
||||
) : Parcelable {
|
||||
|
||||
/* overrides toString method */
|
||||
override fun toString(): String {
|
||||
val stringBuilder: StringBuilder = StringBuilder()
|
||||
stringBuilder.append("Format version: $version\n")
|
||||
stringBuilder.append("Number of stations in collection: ${stations.size}\n\n")
|
||||
stations.forEach {
|
||||
stringBuilder.append("$it\n")
|
||||
}
|
||||
return stringBuilder.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Creates a deep copy of a Collection */
|
||||
fun deepCopy(): Collection {
|
||||
val stationsCopy: MutableList<Station> = mutableListOf()
|
||||
stations.forEach { stationsCopy.add(it.deepCopy()) }
|
||||
return Collection(
|
||||
version = version,
|
||||
stations = stationsCopy,
|
||||
modificationDate = modificationDate
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
107
app/src/main/java/com/michatec/radio/core/Station.kt
Normal file
107
app/src/main/java/com/michatec/radio/core/Station.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Station.kt
|
||||
* Implements the Station class
|
||||
* A Station object holds the base data of a radio
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.core
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.michatec.radio.Keys
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
/*
|
||||
* Station class
|
||||
*/
|
||||
@Keep
|
||||
@Parcelize
|
||||
data class Station(
|
||||
@Expose val uuid: String = UUID.randomUUID().toString(),
|
||||
@Expose var starred: Boolean = false,
|
||||
@Expose var name: String = String(),
|
||||
@Expose var nameManuallySet: Boolean = false,
|
||||
@Expose var streamUris: MutableList<String> = mutableListOf(),
|
||||
@Expose var stream: Int = 0,
|
||||
@Expose var streamContent: String = Keys.MIME_TYPE_UNSUPPORTED,
|
||||
@Expose var homepage: String = String(),
|
||||
@Expose var image: String = String(),
|
||||
@Expose var smallImage: String = String(),
|
||||
@Expose var imageColor: Int = -1,
|
||||
@Expose var imageManuallySet: Boolean = false,
|
||||
@Expose var remoteImageLocation: String = String(),
|
||||
@Expose var remoteStationLocation: String = String(),
|
||||
@Expose var modificationDate: Date = Keys.DEFAULT_DATE,
|
||||
@Expose var isPlaying: Boolean = false,
|
||||
@Expose var radioBrowserStationUuid: String = String(),
|
||||
@Expose var radioBrowserChangeUuid: String = String(),
|
||||
@Expose var bitrate: Int = 0,
|
||||
@Expose var codec: String = String()
|
||||
) : Parcelable {
|
||||
|
||||
|
||||
/* overrides toString method */
|
||||
override fun toString(): String {
|
||||
val stringBuilder: StringBuilder = StringBuilder()
|
||||
stringBuilder.append("Name: ${name}\n")
|
||||
if (streamUris.isNotEmpty()) stringBuilder.append("Stream: ${streamUris[stream]}\n")
|
||||
stringBuilder.append("Last Update: ${modificationDate}\n")
|
||||
stringBuilder.append("Content-Type: ${streamContent}\n")
|
||||
return stringBuilder.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Getter for currently selected stream */
|
||||
fun getStreamUri(): String {
|
||||
return if (streamUris.isNotEmpty()) {
|
||||
streamUris[stream]
|
||||
} else {
|
||||
String()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a Station has the minimum required elements / data */
|
||||
fun isValid(): Boolean {
|
||||
return uuid.isNotEmpty() && name.isNotEmpty() && streamUris.isNotEmpty() && streamUris[stream].isNotEmpty() && modificationDate != Keys.DEFAULT_DATE && streamContent != Keys.MIME_TYPE_UNSUPPORTED
|
||||
}
|
||||
|
||||
|
||||
/* Creates a deep copy of a Station */
|
||||
fun deepCopy(): Station {
|
||||
return Station(
|
||||
uuid = uuid,
|
||||
starred = starred,
|
||||
name = name,
|
||||
nameManuallySet = nameManuallySet,
|
||||
streamUris = streamUris,
|
||||
stream = stream,
|
||||
streamContent = streamContent,
|
||||
homepage = homepage,
|
||||
image = image,
|
||||
smallImage = smallImage,
|
||||
imageColor = imageColor,
|
||||
imageManuallySet = imageManuallySet,
|
||||
remoteImageLocation = remoteImageLocation,
|
||||
remoteStationLocation = remoteStationLocation,
|
||||
modificationDate = modificationDate,
|
||||
isPlaying = isPlaying,
|
||||
radioBrowserStationUuid = radioBrowserStationUuid,
|
||||
radioBrowserChangeUuid = radioBrowserChangeUuid,
|
||||
bitrate = bitrate,
|
||||
codec = codec
|
||||
)
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/com/michatec/radio/dialogs/AddStationDialog.kt
Normal file
133
app/src/main/java/com/michatec/radio/dialogs/AddStationDialog.kt
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* AddStationDialog.kt
|
||||
* Implements the AddStationDialog class
|
||||
* A AddStationDialog shows a dialog with list of stations to import
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-23 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.dialogs
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.SearchResultAdapter
|
||||
|
||||
|
||||
/*
|
||||
* AddStationDialog class
|
||||
*/
|
||||
class AddStationDialog (
|
||||
private val context: Context,
|
||||
private val stationList: List<Station>,
|
||||
private val listener: AddStationDialogListener) :
|
||||
SearchResultAdapter.SearchResultAdapterListener {
|
||||
|
||||
|
||||
/* Interface used to communicate back to activity */
|
||||
interface AddStationDialogListener {
|
||||
fun onAddStationDialog(station: Station)
|
||||
}
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG = AddStationDialog::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var dialog: AlertDialog
|
||||
private lateinit var stationSearchResultList: RecyclerView
|
||||
private lateinit var searchResultAdapter: SearchResultAdapter
|
||||
private var station: Station = Station()
|
||||
|
||||
|
||||
/* Overrides onSearchResultTapped from SearchResultAdapterListener */
|
||||
override fun onSearchResultTapped(result: Station) {
|
||||
station = result
|
||||
// make add button clickable
|
||||
activateAddButton()
|
||||
}
|
||||
|
||||
|
||||
/* Construct and show dialog */
|
||||
fun show() {
|
||||
// prepare dialog builder
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
// set title
|
||||
builder.setTitle(R.string.dialog_add_station_title)
|
||||
|
||||
// get views
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val view = inflater.inflate(R.layout.dialog_add_station, null)
|
||||
stationSearchResultList = view.findViewById(R.id.station_list)
|
||||
|
||||
// set up list of search results
|
||||
setupRecyclerView(context)
|
||||
|
||||
// add okay ("Add") button
|
||||
builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ ->
|
||||
// listen for click on add button
|
||||
listener.onAddStationDialog(station)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
// add cancel button
|
||||
builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ ->
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
// handle outside-click as "no"
|
||||
builder.setOnCancelListener {
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
|
||||
// set dialog view
|
||||
builder.setView(view)
|
||||
|
||||
// create and display dialog
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// initially disable "Add" button
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
}
|
||||
|
||||
|
||||
/* Sets up list of results (RecyclerView) */
|
||||
private fun setupRecyclerView(context: Context) {
|
||||
searchResultAdapter = SearchResultAdapter(this, stationList)
|
||||
stationSearchResultList.adapter = searchResultAdapter
|
||||
val layoutManager: LinearLayoutManager = object: LinearLayoutManager(context) {
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
stationSearchResultList.layoutManager = layoutManager
|
||||
stationSearchResultList.itemAnimator = DefaultItemAnimator()
|
||||
}
|
||||
|
||||
|
||||
/* Implement activateAddButton to enable the "Add" button */
|
||||
override fun activateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
|
||||
}
|
||||
|
||||
|
||||
/* Implement deactivateAddButton to disable the "Add" button */
|
||||
override fun deactivateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
91
app/src/main/java/com/michatec/radio/dialogs/ErrorDialog.kt
Normal file
91
app/src/main/java/com/michatec/radio/dialogs/ErrorDialog.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* ErrorDialog.kt
|
||||
* Implements the ErrorDialog class
|
||||
* An ErrorDialog shows an error dialog with details
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.dialogs
|
||||
|
||||
import android.content.Context
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* ErrorDialog class
|
||||
*/
|
||||
class ErrorDialog {
|
||||
|
||||
/* Construct and show dialog */
|
||||
fun show(
|
||||
context: Context,
|
||||
errorTitle: Int,
|
||||
errorMessage: Int,
|
||||
errorDetails: String = String()
|
||||
) {
|
||||
// prepare dialog builder
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
// set title
|
||||
builder.setTitle(context.getString(errorTitle))
|
||||
|
||||
// get views
|
||||
val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
val view: View = inflater.inflate(R.layout.dialog_generic_with_details, null)
|
||||
val errorMessageView: TextView = view.findViewById(R.id.dialog_message) as TextView
|
||||
val errorDetailsLinkView: TextView = view.findViewById(R.id.dialog_details_link) as TextView
|
||||
val errorDetailsView: TextView = view.findViewById(R.id.dialog_details) as TextView
|
||||
|
||||
// set dialog view
|
||||
builder.setView(view)
|
||||
|
||||
// set detail view
|
||||
val detailsNotEmpty = errorDetails.isNotEmpty()
|
||||
// show/hide details link depending on whether details are empty or not
|
||||
errorDetailsLinkView.isVisible = detailsNotEmpty
|
||||
|
||||
if (detailsNotEmpty) {
|
||||
// allow scrolling on details view
|
||||
errorDetailsView.movementMethod = ScrollingMovementMethod()
|
||||
|
||||
// show and hide details on click
|
||||
errorDetailsLinkView.setOnClickListener {
|
||||
when (errorDetailsView.visibility) {
|
||||
View.GONE -> errorDetailsView.isVisible = true
|
||||
View.VISIBLE -> errorDetailsView.isGone = true
|
||||
View.INVISIBLE -> {
|
||||
return@setOnClickListener
|
||||
}
|
||||
}
|
||||
}
|
||||
// set details text view
|
||||
errorDetailsView.text = errorDetails
|
||||
}
|
||||
|
||||
// set text views
|
||||
errorMessageView.text = context.getString(errorMessage)
|
||||
|
||||
// add okay button
|
||||
builder.setPositiveButton(R.string.dialog_generic_button_okay) { _, _ ->
|
||||
// listen for click on okay button
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// display error dialog
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* FindStationDialog.kt
|
||||
* Implements the FindStationDialog class
|
||||
* A FindStationDialog shows a dialog with search box and list of results
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.dialogs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ProgressBar
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.DirectInputCheck
|
||||
import com.michatec.radio.search.RadioBrowserResult
|
||||
import com.michatec.radio.search.RadioBrowserSearch
|
||||
import com.michatec.radio.search.SearchResultAdapter
|
||||
|
||||
|
||||
/*
|
||||
* FindStationDialog class
|
||||
*/
|
||||
class FindStationDialog (
|
||||
private val context: Context,
|
||||
private val listener: FindStationDialogListener):
|
||||
SearchResultAdapter.SearchResultAdapterListener,
|
||||
RadioBrowserSearch.RadioBrowserSearchListener,
|
||||
DirectInputCheck.DirectInputCheckListener {
|
||||
|
||||
/* Interface used to communicate back to activity */
|
||||
interface FindStationDialogListener {
|
||||
fun onFindStationDialog(station: Station) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var dialog: AlertDialog
|
||||
private lateinit var stationSearchBoxView: SearchView
|
||||
private lateinit var searchRequestProgressIndicator: ProgressBar
|
||||
private lateinit var noSearchResultsTextView: MaterialTextView
|
||||
private lateinit var stationSearchResultList: RecyclerView
|
||||
private lateinit var searchResultAdapter: SearchResultAdapter
|
||||
private lateinit var radioBrowserSearch: RadioBrowserSearch
|
||||
private lateinit var directInputCheck: DirectInputCheck
|
||||
private var currentSearchString: String = String()
|
||||
private val handler: Handler = Handler(Looper.getMainLooper())
|
||||
private var station: Station = Station()
|
||||
|
||||
|
||||
/* Overrides onSearchResultTapped from SearchResultAdapterListener */
|
||||
override fun onSearchResultTapped(result: Station) {
|
||||
station = result
|
||||
// hide keyboard
|
||||
val imm: InputMethodManager =
|
||||
context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(stationSearchBoxView.windowToken, 0)
|
||||
// make add button clickable
|
||||
activateAddButton()
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
|
||||
if (results.isNotEmpty()) {
|
||||
val stationList: List<Station> = results.map {it.toStation()}
|
||||
searchResultAdapter.searchResults = stationList
|
||||
searchResultAdapter.notifyDataSetChanged()
|
||||
resetLayout(clearAdapter = false)
|
||||
} else {
|
||||
showNoResultsError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onDirectInputCheck from DirectInputCheck */
|
||||
override fun onDirectInputCheck(stationList: MutableList<Station>) {
|
||||
if (stationList.isNotEmpty()) {
|
||||
val startPosition = searchResultAdapter.searchResults.size
|
||||
searchResultAdapter.searchResults = stationList
|
||||
searchResultAdapter.notifyItemRangeInserted(startPosition, stationList.size)
|
||||
resetLayout(clearAdapter = false)
|
||||
} else {
|
||||
showNoResultsError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Construct and show dialog */
|
||||
fun show() {
|
||||
// initialize a radio browser search and direct url input check
|
||||
radioBrowserSearch = RadioBrowserSearch(this)
|
||||
directInputCheck = DirectInputCheck(this)
|
||||
|
||||
// prepare dialog builder
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
// set title
|
||||
builder.setTitle(R.string.dialog_find_station_title)
|
||||
|
||||
// get views
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val view = inflater.inflate(R.layout.dialog_find_station, null)
|
||||
stationSearchBoxView = view.findViewById(R.id.station_search_box_view)
|
||||
searchRequestProgressIndicator = view.findViewById(R.id.search_request_progress_indicator)
|
||||
stationSearchResultList = view.findViewById(R.id.station_search_result_list)
|
||||
noSearchResultsTextView = view.findViewById(R.id.no_results_text_view)
|
||||
noSearchResultsTextView.isGone = true
|
||||
|
||||
// set up list of search results
|
||||
setupRecyclerView(context)
|
||||
|
||||
// add okay ("Add") button
|
||||
builder.setPositiveButton(R.string.dialog_find_station_button_add) { _, _ ->
|
||||
// listen for click on add button
|
||||
listener.onFindStationDialog(station)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
// add cancel button
|
||||
builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ ->
|
||||
// listen for click on cancel button
|
||||
radioBrowserSearch.stopSearchRequest()
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
// handle outside-click as "no"
|
||||
builder.setOnCancelListener {
|
||||
radioBrowserSearch.stopSearchRequest()
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
}
|
||||
|
||||
// listen for input
|
||||
stationSearchBoxView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextChange(query: String): Boolean {
|
||||
handleSearchBoxLiveInput(context, query)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
handleSearchBoxInput(context, query)
|
||||
searchResultAdapter.stopPrePlayback()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// set dialog view
|
||||
builder.setView(view)
|
||||
|
||||
// create and display dialog
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// initially disable "Add" button
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isAllCaps = true
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).isAllCaps = true
|
||||
}
|
||||
|
||||
|
||||
/* Sets up list of results (RecyclerView) */
|
||||
private fun setupRecyclerView(context: Context) {
|
||||
searchResultAdapter = SearchResultAdapter(this, listOf())
|
||||
stationSearchResultList.adapter = searchResultAdapter
|
||||
val layoutManager: LinearLayoutManager = object : LinearLayoutManager(context) {
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
stationSearchResultList.layoutManager = layoutManager
|
||||
stationSearchResultList.itemAnimator = DefaultItemAnimator()
|
||||
}
|
||||
|
||||
|
||||
/* Handles user input into search box - user has to submit the search */
|
||||
private fun handleSearchBoxInput(context: Context, query: String) {
|
||||
when {
|
||||
// handle empty search box input
|
||||
query.isEmpty() -> {
|
||||
resetLayout(clearAdapter = true)
|
||||
}
|
||||
// handle direct URL input
|
||||
query.startsWith("http") -> {
|
||||
directInputCheck.checkStationAddress(context, query)
|
||||
}
|
||||
// handle search string input
|
||||
else -> {
|
||||
showProgressIndicator()
|
||||
radioBrowserSearch.searchStation(context, query, Keys.SEARCH_TYPE_BY_KEYWORD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Handles live user input into search box */
|
||||
private fun handleSearchBoxLiveInput(context: Context, query: String) {
|
||||
currentSearchString = query
|
||||
if (query.startsWith("htt")) {
|
||||
// handle direct URL input
|
||||
directInputCheck.checkStationAddress(context, query)
|
||||
} else if (query.contains(" ") || query.length > 2) {
|
||||
// show progress indicator
|
||||
showProgressIndicator()
|
||||
// handle search string input - delay request to manage server load (not sure if necessary)
|
||||
handler.postDelayed({
|
||||
// only start search if query is the same as one second ago
|
||||
if (currentSearchString == query) radioBrowserSearch.searchStation(
|
||||
context,
|
||||
query,
|
||||
Keys.SEARCH_TYPE_BY_KEYWORD
|
||||
)
|
||||
}, 100)
|
||||
} else if (query.isEmpty()) {
|
||||
resetLayout(clearAdapter = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Makes the "Add" button clickable */
|
||||
override fun activateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
}
|
||||
|
||||
override fun deactivateAddButton() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
}
|
||||
|
||||
|
||||
/* Resets the dialog layout to default state */
|
||||
private fun resetLayout(clearAdapter: Boolean = false) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
searchResultAdapter.resetSelection(clearAdapter)
|
||||
}
|
||||
|
||||
|
||||
/* Display the "No Results" error - hide other unneeded views */
|
||||
private fun showNoResultsError() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isGone = true
|
||||
noSearchResultsTextView.isVisible = true
|
||||
}
|
||||
|
||||
|
||||
/* Display the "No Results" error - hide other unneeded views */
|
||||
private fun showProgressIndicator() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
searchRequestProgressIndicator.isVisible = true
|
||||
noSearchResultsTextView.isGone = true
|
||||
}
|
||||
|
||||
}
|
||||
108
app/src/main/java/com/michatec/radio/dialogs/YesNoDialog.kt
Normal file
108
app/src/main/java/com/michatec/radio/dialogs/YesNoDialog.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* YesNoDialog
|
||||
* Implements the YesNoDialog class
|
||||
* A YesNoDialog asks the user if he/she wants to do something or not
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.dialogs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* YesNoDialog class
|
||||
*/
|
||||
class YesNoDialog(private var yesNoDialogListener: YesNoDialogListener) {
|
||||
|
||||
/* Interface used to communicate back to activity */
|
||||
interface YesNoDialogListener {
|
||||
fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var dialog: AlertDialog
|
||||
|
||||
|
||||
/* Construct and show dialog - variant: message from string */
|
||||
fun show(
|
||||
context: Context,
|
||||
type: Int,
|
||||
title: Int = Keys.EMPTY_STRING_RESOURCE,
|
||||
message: Int,
|
||||
yesButton: Int = R.string.dialog_yes_no_positive_button_default,
|
||||
noButton: Int = R.string.dialog_generic_button_cancel,
|
||||
payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT,
|
||||
payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING
|
||||
) {
|
||||
// extract string from message resource and feed into main show method
|
||||
show(
|
||||
context,
|
||||
type,
|
||||
title,
|
||||
context.getString(message),
|
||||
yesButton,
|
||||
noButton,
|
||||
payload,
|
||||
payloadString
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Construct and show dialog */
|
||||
fun show(
|
||||
context: Context,
|
||||
type: Int,
|
||||
title: Int = Keys.EMPTY_STRING_RESOURCE,
|
||||
messageString: String,
|
||||
yesButton: Int = R.string.dialog_yes_no_positive_button_default,
|
||||
noButton: Int = R.string.dialog_generic_button_cancel,
|
||||
payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT,
|
||||
payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING
|
||||
) {
|
||||
|
||||
// prepare dialog builder
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
// set title and message
|
||||
builder.setMessage(messageString)
|
||||
if (title != Keys.EMPTY_STRING_RESOURCE) {
|
||||
builder.setTitle(context.getString(title))
|
||||
}
|
||||
|
||||
|
||||
// add yes button
|
||||
builder.setPositiveButton(yesButton) { _, _ ->
|
||||
// listen for click on yes button
|
||||
yesNoDialogListener.onYesNoDialog(type, true, payload, payloadString)
|
||||
}
|
||||
|
||||
// add no button
|
||||
builder.setNegativeButton(noButton) { _, _ ->
|
||||
// listen for click on no button
|
||||
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
|
||||
}
|
||||
|
||||
// handle outside-click as "no"
|
||||
builder.setOnCancelListener {
|
||||
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
|
||||
}
|
||||
|
||||
// display dialog
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* ArrayListExt.kt
|
||||
* Implements the ArrayListExt extension methods
|
||||
* Useful extension methods for ArrayLists
|
||||
* Source: https://raw.githubusercontent.com/googlesamples/android-UniversalMusicPlayer/master/common/src/main/java/com/example/android/uamp/media/extensions/MediaMetadataCompatExt.kt
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.extensions
|
||||
|
||||
|
||||
/* Creates a "real" copy of an ArrayList<Long> - useful for preventing concurrent modification issues */
|
||||
fun ArrayList<Long>.copy(): ArrayList<Long> {
|
||||
val copy: ArrayList<Long> = ArrayList()
|
||||
this.forEach { copy.add(it) }
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* MediaControllerExt.kt
|
||||
* Implements the MediaControllerExt extension methods
|
||||
* Useful extension methods for MediaController
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.extensions
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.helpers.CollectionHelper
|
||||
|
||||
|
||||
/* Starts the sleep timer */
|
||||
fun MediaController.startSleepTimer(timerDurationMillis: Long) {
|
||||
val bundle = Bundle().apply {
|
||||
putLong(Keys.SLEEP_TIMER_DURATION, timerDurationMillis)
|
||||
}
|
||||
sendCustomCommand(SessionCommand(Keys.CMD_START_SLEEP_TIMER, bundle), bundle)
|
||||
}
|
||||
|
||||
|
||||
/* Cancels the sleep timer */
|
||||
fun MediaController.cancelSleepTimer() {
|
||||
sendCustomCommand(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY), Bundle.EMPTY)
|
||||
}
|
||||
|
||||
|
||||
/* Request sleep timer remaining */
|
||||
fun MediaController.requestSleepTimerRemaining(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY),
|
||||
Bundle.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Request sleep timer remaining */
|
||||
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
|
||||
Bundle.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Starts playback with a new media item */
|
||||
fun MediaController.play(context: Context, station: Station) {
|
||||
if (isPlaying) pause()
|
||||
// set media item, prepare and play
|
||||
setMediaItem(CollectionHelper.buildMediaItem(context, station))
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
|
||||
|
||||
/* Starts playback with of a stream url */
|
||||
fun MediaController.playStreamDirectly(streamUri: String) {
|
||||
sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_PLAY_STREAM, Bundle.EMPTY),
|
||||
bundleOf(Pair(Keys.KEY_STREAM_URI, streamUri))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* AppThemeHelper.kt
|
||||
* Implements the AppThemeHelper object
|
||||
* A AppThemeHelper can set the different app themes: Light Mode, Dark Mode, Follow System
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* AppThemeHelper object
|
||||
*/
|
||||
object AppThemeHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = AppThemeHelper::class.java.simpleName
|
||||
|
||||
private val sTypedValue = TypedValue()
|
||||
|
||||
/* Sets app theme */
|
||||
fun setTheme(nightModeState: String) {
|
||||
when (nightModeState) {
|
||||
Keys.STATE_THEME_DARK_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
// turn on dark mode
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
Log.i(TAG, "Dark Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_LIGHT_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_NO) {
|
||||
// turn on light mode
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
Log.i(TAG, "Theme: Light Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
|
||||
// turn on mode "follow system"
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Log.i(TAG, "Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// turn on mode "follow system"
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Log.i(TAG, "Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Returns a readable String for currently selected App Theme */
|
||||
fun getCurrentTheme(context: Context): String {
|
||||
return when (PreferencesHelper.loadThemeSelection()) {
|
||||
Keys.STATE_THEME_LIGHT_MODE -> context.getString(R.string.pref_theme_selection_mode_light)
|
||||
Keys.STATE_THEME_DARK_MODE -> context.getString(R.string.pref_theme_selection_mode_dark)
|
||||
else -> context.getString(R.string.pref_theme_selection_mode_device_default)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ColorInt
|
||||
fun getColor(context: Context, @AttrRes resource: Int): Int {
|
||||
val a: TypedArray = context.obtainStyledAttributes(sTypedValue.data, intArrayOf(resource))
|
||||
val color = a.getColor(0, 0)
|
||||
a.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
}
|
||||
64
app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt
Normal file
64
app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* AudioHelper.kt
|
||||
* Implements the AudioHelper object
|
||||
* A AudioHelper provides helper methods for handling audio files
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.extractor.metadata.icy.IcyHeaders
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import com.michatec.radio.Keys
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
/*
|
||||
* AudioHelper object
|
||||
*/
|
||||
object AudioHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = AudioHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Extract audio stream metadata */
|
||||
fun getMetadataString(metadata: Metadata): String {
|
||||
var metadataString = String()
|
||||
for (i in 0 until metadata.length()) {
|
||||
// extract IceCast metadata
|
||||
when (val entry = metadata.get(i)) {
|
||||
is IcyInfo -> {
|
||||
metadataString = entry.title.toString()
|
||||
}
|
||||
|
||||
is IcyHeaders -> {
|
||||
Log.i(TAG, "icyHeaders:" + entry.name + " - " + entry.genre)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unsupported metadata received (type = ${entry.javaClass.simpleName})")
|
||||
}
|
||||
}
|
||||
// TODO implement HLS metadata extraction (Id3Frame / PrivFrame)
|
||||
// https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/metadata/Metadata.Entry.html
|
||||
}
|
||||
// ensure a max length of the metadata string
|
||||
if (metadataString.isNotEmpty()) {
|
||||
metadataString = metadataString.substring(0, min(metadataString.length, Keys.DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY))
|
||||
}
|
||||
return metadataString
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
179
app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt
Normal file
179
app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* BackupHelper.kt
|
||||
* Implements the BackupHelper object
|
||||
* A BackupHelper provides helper methods for backing up and restoring the radio station collection
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.michatec.radio.R
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
object BackupHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = BackupHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Compresses all files in the app's external files directory into destination zip file */
|
||||
fun backup(view: View, context: Context, destinationUri: Uri) {
|
||||
val sourceFolder: File? = context.getExternalFilesDir("")
|
||||
if (sourceFolder != null && sourceFolder.isDirectory) {
|
||||
Snackbar.make(
|
||||
view,
|
||||
"${
|
||||
FileHelper.getFileName(
|
||||
context,
|
||||
destinationUri
|
||||
)
|
||||
} ${context.getString(R.string.toastmessage_backed_up)}",
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val outputStream: OutputStream? = resolver.openOutputStream(destinationUri)
|
||||
ZipOutputStream(BufferedOutputStream(outputStream)).use { zipOutputStream ->
|
||||
zipOutputStream.use {
|
||||
zipFolder(it, sourceFolder, "")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Unable to access External Storage.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Extracts zip backup file and restores files and folders - Credit: https://www.baeldung.com/java-compress-and-uncompress*/
|
||||
fun restore(view: View, context: Context, sourceUri: Uri) {
|
||||
Snackbar.make(view, R.string.toastmessage_restored, Snackbar.LENGTH_LONG).show()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// bypass "ZipException" for Android 14 or above applications when zip file names contain ".." or start with "/"
|
||||
dalvik.system.ZipPathValidator.clearCallback()
|
||||
}
|
||||
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val sourceInputStream: InputStream? = resolver.openInputStream(sourceUri)
|
||||
val destinationFolder: File? = context.getExternalFilesDir("")
|
||||
val buffer = ByteArray(1024)
|
||||
val zipInputStream = ZipInputStream(sourceInputStream)
|
||||
var zipEntry: ZipEntry? = zipInputStream.nextEntry
|
||||
|
||||
// iterate through ZipInputStream until last ZipEntry
|
||||
while (zipEntry != null) {
|
||||
try {
|
||||
val newFile: File = getFile(destinationFolder!!, zipEntry)
|
||||
when (zipEntry.isDirectory) {
|
||||
// CASE: Folder
|
||||
true -> {
|
||||
// create folder if zip entry is a folder
|
||||
if (!newFile.isDirectory && !newFile.mkdirs()) {
|
||||
Log.w(TAG, "Failed to create directory $newFile")
|
||||
}
|
||||
}
|
||||
// CASE: File
|
||||
false -> {
|
||||
// create parent directory, if necessary
|
||||
val parent: File? = newFile.parentFile
|
||||
if (parent != null && !parent.isDirectory && !parent.mkdirs()) {
|
||||
Log.w(TAG, "Failed to create directory $parent")
|
||||
}
|
||||
// write file content
|
||||
val fileOutputStream = FileOutputStream(newFile)
|
||||
var len: Int
|
||||
while (zipInputStream.read(buffer).also { len = it } > 0) {
|
||||
fileOutputStream.write(buffer, 0, len)
|
||||
}
|
||||
fileOutputStream.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to safely create file. $e")
|
||||
}
|
||||
// get next entry - zipEntry will be null, when zipInputStream has no more entries left
|
||||
zipEntry = zipInputStream.nextEntry
|
||||
}
|
||||
zipInputStream.closeEntry()
|
||||
zipInputStream.close()
|
||||
|
||||
// notify CollectionViewModel that collection has changed
|
||||
CollectionHelper.sendCollectionBroadcast(
|
||||
context,
|
||||
modificationDate = Calendar.getInstance().time
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Compresses folder into ZIP file - Credit: https://stackoverflow.com/a/52216574 */
|
||||
private fun zipFolder(zipOutputStream: ZipOutputStream, source: File, parentDirPath: String) {
|
||||
// source.listFiles() will return null, if source is not a directory
|
||||
if (source.isDirectory) {
|
||||
val data = ByteArray(2048)
|
||||
// get all File objects in folder
|
||||
for (file in source.listFiles()!!) {
|
||||
// make sure that path does not start with a separator (/)
|
||||
val path: String = if (parentDirPath.isEmpty()) file.name else parentDirPath + File.separator + file.name
|
||||
when (file.isDirectory) {
|
||||
// CASE: Folder
|
||||
true -> {
|
||||
// call zipFolder recursively to add files within this folder
|
||||
zipFolder(zipOutputStream, file, path)
|
||||
}
|
||||
// CASE: File
|
||||
false -> {
|
||||
FileInputStream(file).use { fileInputStream ->
|
||||
BufferedInputStream(fileInputStream).use { bufferedInputStream ->
|
||||
val entry = ZipEntry(path)
|
||||
entry.time = file.lastModified()
|
||||
entry.size = file.length()
|
||||
zipOutputStream.putNextEntry(entry)
|
||||
while (true) {
|
||||
val readBytes = bufferedInputStream.read(data)
|
||||
if (readBytes == -1) {
|
||||
break
|
||||
}
|
||||
zipOutputStream.write(data, 0, readBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Normalize file path - protects against zip slip attack */
|
||||
@Throws(IOException::class)
|
||||
private fun getFile(destinationFolder: File, zipEntry: ZipEntry): File {
|
||||
val destinationFile = File(destinationFolder, zipEntry.name)
|
||||
val destinationFolderPath = destinationFolder.canonicalPath
|
||||
val destinationFilePath = destinationFile.canonicalPath
|
||||
// make sure that zipEntry path is in the destination folder
|
||||
if (!destinationFilePath.startsWith(destinationFolderPath + File.separator)) {
|
||||
throw IOException("ZIP entry is not within of the destination folder: " + zipEntry.name)
|
||||
}
|
||||
return destinationFile
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
773
app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
Normal file
773
app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
Normal file
@@ -0,0 +1,773 @@
|
||||
/*
|
||||
* CollectionHelper.kt
|
||||
* Implements the CollectionHelper object
|
||||
* A CollectionHelper provides helper methods for the collection of stations
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.DirectInputCheck
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* CollectionHelper object
|
||||
*/
|
||||
object CollectionHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = CollectionHelper::class.java.simpleName
|
||||
|
||||
/* Checks if station is already in collection */
|
||||
private fun isNewStation(collection: Collection, station: Station): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.getStreamUri() == station.getStreamUri()) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Checks if station is already in collection */
|
||||
fun isNewStation(collection: Collection, remoteStationLocation: String): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.remoteStationLocation == remoteStationLocation) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a newer collection of radio stations is available on storage */
|
||||
fun isNewerCollectionAvailable(date: Date): Boolean {
|
||||
var newerCollectionAvailable = false
|
||||
val modificationDate: Date = PreferencesHelper.loadCollectionModificationDate()
|
||||
if (modificationDate.after(date) || date == Keys.DEFAULT_DATE) {
|
||||
newerCollectionAvailable = true
|
||||
}
|
||||
return newerCollectionAvailable
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from previously downloaded playlist file */
|
||||
fun createStationFromPlaylistFile(
|
||||
context: Context,
|
||||
localFileUri: Uri,
|
||||
remoteFileLocation: String
|
||||
): Station {
|
||||
// read station playlist
|
||||
val station: Station =
|
||||
FileHelper.readStationPlaylist(context.contentResolver.openInputStream(localFileUri))
|
||||
if (station.name.isEmpty()) {
|
||||
// construct name from file name - strips file extension
|
||||
station.name = FileHelper.getFileName(context, localFileUri).substringBeforeLast(".")
|
||||
}
|
||||
station.remoteStationLocation = remoteFileLocation
|
||||
station.remoteImageLocation = getFaviconAddress(remoteFileLocation)
|
||||
station.modificationDate = GregorianCalendar.getInstance().time
|
||||
return station
|
||||
}
|
||||
|
||||
|
||||
/* Updates radio station in collection */
|
||||
fun updateStation(context: Context, collection: Collection, station: Station): Collection {
|
||||
var updatedCollection: Collection = collection
|
||||
|
||||
// CASE: Update station retrieved from radio browser
|
||||
if (station.radioBrowserStationUuid.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.radioBrowserStationUuid == station.radioBrowserStationUuid) {
|
||||
// update station in collection with values from new station
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
it.remoteStationLocation = station.remoteStationLocation
|
||||
it.homepage = station.homepage
|
||||
// update name - if not changed previously by user
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
// re-download station image - if new URL and not changed previously by user
|
||||
DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
}
|
||||
|
||||
// CASE: Update station retrieved via playlist
|
||||
else if (station.remoteStationLocation.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.remoteStationLocation == station.remoteStationLocation) {
|
||||
// update stream uri, mime type and station image url
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
// update name - if not changed previously by user
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
// re-download station image - if not changed previously by user
|
||||
if (!it.imageManuallySet) DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
}
|
||||
|
||||
return updatedCollection
|
||||
}
|
||||
|
||||
|
||||
/* Adds new radio station to collection */
|
||||
fun addStation(context: Context, collection: Collection, newStation: Station): Collection {
|
||||
// check validity
|
||||
if (!newStation.isValid()) {
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return collection
|
||||
}
|
||||
// duplicate check
|
||||
else if (!isNewStation(collection, newStation)) {
|
||||
// update station
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, R.string.toastmessage_station_duplicate, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return collection
|
||||
}
|
||||
// all clear -> add station
|
||||
else {
|
||||
var updatedCollection: Collection = collection
|
||||
val updatedStationList: MutableList<Station> = collection.stations.toMutableList()
|
||||
// add station
|
||||
updatedStationList.add(newStation)
|
||||
updatedCollection.stations = updatedStationList
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
// download station image
|
||||
DownloadHelper.updateStationImage(context, newStation)
|
||||
// return updated collection
|
||||
return updatedCollection
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sets station image - determines station by remote image file location */
|
||||
fun setStationImageWithRemoteLocation(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: String,
|
||||
remoteFileLocation: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
// compare image location protocol-agnostic (= without http / https)
|
||||
if (station.remoteImageLocation.substringAfter(":") == remoteFileLocation.substringAfter(
|
||||
":"
|
||||
)
|
||||
) {
|
||||
station.smallImage = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri.toUri(),
|
||||
Keys.SIZE_STATION_IMAGE_CARD,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.image = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri.toUri(),
|
||||
Keys.SIZE_STATION_IMAGE_MAXIMUM,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri.toUri())
|
||||
station.imageManuallySet = imageManuallySet
|
||||
}
|
||||
}
|
||||
// save and return collection
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Sets station image - determines station by remote image file location */
|
||||
fun setStationImageWithStationUuid(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: Uri,
|
||||
stationUuid: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
// find station by uuid
|
||||
if (station.uuid == stationUuid) {
|
||||
station.smallImage = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri,
|
||||
Keys.SIZE_STATION_IMAGE_CARD,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.image = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri,
|
||||
Keys.SIZE_STATION_IMAGE_MAXIMUM,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri)
|
||||
station.imageManuallySet = imageManuallySet
|
||||
}
|
||||
}
|
||||
// save and return collection
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Clears an image folder for a given station */
|
||||
fun clearImagesFolder(context: Context, station: Station) {
|
||||
// clear image folder
|
||||
val imagesFolder = File(
|
||||
context.getExternalFilesDir(""),
|
||||
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
|
||||
)
|
||||
FileHelper.clearFolder(imagesFolder, 0)
|
||||
}
|
||||
|
||||
|
||||
/* Deletes Images of a given station */
|
||||
fun deleteStationImages(context: Context, station: Station) {
|
||||
val imagesFolder = File(
|
||||
context.getExternalFilesDir(""),
|
||||
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
|
||||
)
|
||||
FileHelper.clearFolder(imagesFolder, 0, true)
|
||||
}
|
||||
|
||||
|
||||
/* Get station from collection for given UUID */
|
||||
fun getStation(collection: Collection, stationUuid: String): Station {
|
||||
collection.stations.forEach { station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return station
|
||||
}
|
||||
}
|
||||
// fallback: return first station
|
||||
return if (collection.stations.isNotEmpty()) {
|
||||
collection.stations.first()
|
||||
} else {
|
||||
Station()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Gets MediaIem for next station within collection */
|
||||
fun getNextMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
val currentStationPosition: Int = getStationPosition(collection, stationUuid)
|
||||
return if (collection.stations.isEmpty() || currentStationPosition == -1) {
|
||||
buildMediaItem(context, Station())
|
||||
} else if (currentStationPosition < collection.stations.size -1) {
|
||||
buildMediaItem(context, collection.stations[currentStationPosition + 1])
|
||||
} else {
|
||||
buildMediaItem(context, collection.stations.first())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Gets MediaIem for previous station within collection */
|
||||
fun getPreviousMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
val currentStationPosition: Int = getStationPosition(collection, stationUuid)
|
||||
return if (collection.stations.isEmpty() || currentStationPosition == -1) {
|
||||
buildMediaItem(context, Station())
|
||||
} else if (currentStationPosition > 0) {
|
||||
buildMediaItem(context, collection.stations[currentStationPosition - 1])
|
||||
} else {
|
||||
buildMediaItem(context, collection.stations.last())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get the position from collection for given UUID */
|
||||
fun getStationPosition(collection: Collection, stationUuid: String): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
/* Get the position from collection for given radioBrowserStationUuid */
|
||||
fun getStationPositionFromRadioBrowserStationUuid(
|
||||
collection: Collection,
|
||||
radioBrowserStationUuid: String
|
||||
): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.radioBrowserStationUuid == radioBrowserStationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
/* Returns the children stations under under root (simple media library structure: root > stations) */
|
||||
fun getChildren(context: Context, collection: Collection): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
collection.stations.forEach { station ->
|
||||
mediaItems.add(buildMediaItem(context, station))
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
|
||||
/* Returns media item for given station id */
|
||||
fun getItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
return buildMediaItem(context, getStation(collection, stationUuid))
|
||||
}
|
||||
|
||||
|
||||
/* Returns media item for last played station */
|
||||
fun getRecent(context: Context, collection: Collection): MediaItem {
|
||||
return buildMediaItem(context, getStation(collection, PreferencesHelper.loadLastPlayedStationUuid()))
|
||||
}
|
||||
|
||||
|
||||
/* Returns the library root item */
|
||||
fun getRootItem(): MediaItem {
|
||||
val metadata: MediaMetadata = MediaMetadata.Builder()
|
||||
.setTitle("Root Folder")
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("[rootID]")
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
/* Saves the playback state of a given station */
|
||||
fun savePlaybackState(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
stationUuid: String,
|
||||
isPlaying: Boolean
|
||||
): Collection {
|
||||
collection.stations.forEach {
|
||||
// reset playback state everywhere
|
||||
it.isPlaying = false
|
||||
// set given playback state at this station
|
||||
if (it.uuid == stationUuid) {
|
||||
it.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
// save collection and store modification date
|
||||
collection.modificationDate = saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Saves collection of radio stations */
|
||||
fun saveCollection(context: Context, collection: Collection, async: Boolean = true): Date {
|
||||
Log.v(
|
||||
TAG,
|
||||
"Saving collection of radio stations to storage. Async = ${async}. Size = ${collection.stations.size}"
|
||||
)
|
||||
// get modification date
|
||||
val date: Date = Calendar.getInstance().time
|
||||
collection.modificationDate = date
|
||||
// save collection to storage
|
||||
when (async) {
|
||||
true -> {
|
||||
CoroutineScope(IO).launch {
|
||||
// save collection on background thread
|
||||
FileHelper.saveCollectionSuspended(context, collection, date)
|
||||
// broadcast collection update
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
// save collection
|
||||
FileHelper.saveCollection(context, collection, date)
|
||||
// broadcast collection update
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
// return modification date
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from playlist URLs and stream address URLs */
|
||||
suspend fun createStationsFromUrl(query: String, lastCheckedAddress: String = String()): List<Station> {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val contentType: String = NetworkHelper.detectContentType(query).type.lowercase(Locale.getDefault())
|
||||
val directInputCheck: DirectInputCheck? = null
|
||||
|
||||
// CASE: M3U playlist detected
|
||||
if (Keys.MIME_TYPES_M3U.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readM3uPlaylistContent(lines))
|
||||
}
|
||||
// CASE: PLS playlist detected
|
||||
else if (Keys.MIME_TYPES_PLS.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readPlsPlaylistContent(lines))
|
||||
}
|
||||
// CASE: stream address detected
|
||||
else if (Keys.MIME_TYPES_MPEG.contains(contentType) or
|
||||
Keys.MIME_TYPES_OGG.contains(contentType) or
|
||||
Keys.MIME_TYPES_AAC.contains(contentType) or
|
||||
Keys.MIME_TYPES_HLS.contains(contentType)) {
|
||||
// process Icecast stream and extract metadata
|
||||
directInputCheck?.processIcecastStream(query, stationList)
|
||||
// create station and add to collection
|
||||
val station = Station(name = query, streamUris = mutableListOf(query), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
if (lastCheckedAddress != query) {
|
||||
stationList.add(station)
|
||||
}
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from URI pointing to a local file */
|
||||
fun createStationListFromContentUri(context: Context, contentUri: Uri): List<Station> {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val fileType: String = FileHelper.getContentType(context, contentUri)
|
||||
// CASE: M3U playlist detected
|
||||
if (Keys.MIME_TYPES_M3U.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readM3uPlaylistContent(playlist))
|
||||
}
|
||||
// CASE: PLS playlist detected
|
||||
else if (Keys.MIME_TYPES_PLS.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readPlsPlaylistContent(playlist))
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
|
||||
/* Reads a m3u playlist and returns a list of stations */
|
||||
private fun readM3uPlaylistContent(playlist: List<String>): List<Station> {
|
||||
val stations: MutableList<Station> = mutableListOf()
|
||||
var name = String()
|
||||
var streamUri: String
|
||||
var contentType: String
|
||||
|
||||
playlist.forEach { line ->
|
||||
// get name of station
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
name = line.substringAfter(",").trim()
|
||||
}
|
||||
// get stream uri and check mime type
|
||||
else if (line.isNotBlank() && !line.startsWith("#")) {
|
||||
streamUri = line.trim()
|
||||
// use the stream address as the name if no name is specified
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
// store station in list if mime type is supported
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
stations.add(station)
|
||||
}
|
||||
// reset name for the next station - useful if playlist does not provide name(s)
|
||||
name = String()
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
|
||||
/* Reads a pls playlist and returns a list of stations */
|
||||
private fun readPlsPlaylistContent(playlist: List<String>): List<Station> {
|
||||
val stations: MutableList<Station> = mutableListOf()
|
||||
var name = String()
|
||||
var streamUri: String
|
||||
var contentType: String
|
||||
|
||||
playlist.forEachIndexed { index, line ->
|
||||
// get stream uri and check mime type
|
||||
if (line.startsWith("File")) {
|
||||
streamUri = line.substringAfter("=").trim()
|
||||
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
// look for the matching station name
|
||||
val number: String = line.substring(4 /* File */, line.indexOf("="))
|
||||
val lineBeforeIndex: Int = index - 1
|
||||
val lineAfterIndex: Int = index + 1
|
||||
// first: check the line before
|
||||
if (lineBeforeIndex >= 0) {
|
||||
val lineBefore: String = playlist[lineBeforeIndex]
|
||||
if (lineBefore.startsWith("Title$number")) {
|
||||
name = lineBefore.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
// then: check the line after
|
||||
if (name.isEmpty() && lineAfterIndex < playlist.size) {
|
||||
val lineAfter: String = playlist[lineAfterIndex]
|
||||
if (lineAfter.startsWith("Title$number")) {
|
||||
name = lineAfter.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
// fallback: use stream uri as name
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
// add station
|
||||
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
stations.add(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
|
||||
/* Export collection of stations as M3U */
|
||||
fun exportCollectionM3u(context: Context, collection: Collection) {
|
||||
Log.v(TAG, "Exporting collection of stations as M3U")
|
||||
// export collection as M3U - launch = fire & forget (no return value from save collection)
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsM3uSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Create M3U string from collection of stations */
|
||||
fun createM3uString(collection: Collection): String {
|
||||
val m3uString = StringBuilder()
|
||||
/* Extended M3U Format
|
||||
#EXTM3U
|
||||
#EXTINF:-1,My Cool Stream
|
||||
http://www.site.com:8000/listen.pls
|
||||
*/
|
||||
|
||||
// add opening tag
|
||||
m3uString.append("#EXTM3U")
|
||||
m3uString.append("\n")
|
||||
|
||||
// add name and stream address
|
||||
collection.stations.forEach { station ->
|
||||
m3uString.append("\n")
|
||||
m3uString.append("#EXTINF:-1,")
|
||||
m3uString.append(station.name)
|
||||
m3uString.append("\n")
|
||||
m3uString.append(station.getStreamUri())
|
||||
m3uString.append("\n")
|
||||
}
|
||||
|
||||
return m3uString.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Export collection of stations as PLS */
|
||||
fun exportCollectionPls(context: Context, collection: Collection) {
|
||||
Log.v(TAG, "Exporting collection of stations as PLS")
|
||||
// export collection as PLS - launch = fire & forget (no return value from save collection)
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsPlsSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Create PLS string from collection of stations */
|
||||
fun createPlsString(collection: Collection): String {
|
||||
/* Extended PLS Format
|
||||
[playlist]
|
||||
|
||||
Title1=My Cool Stream
|
||||
File1=http://www.site.com:8000/listen.pls
|
||||
Length1=-1
|
||||
|
||||
NumberOfEntries=1
|
||||
Version=2
|
||||
*/
|
||||
|
||||
val plsString = StringBuilder()
|
||||
var counter = 1
|
||||
|
||||
// add opening tag
|
||||
plsString.append("[playlist]")
|
||||
plsString.append("\n")
|
||||
|
||||
// add name and stream address
|
||||
collection.stations.forEach { station ->
|
||||
plsString.append("\n")
|
||||
plsString.append("Title$counter=")
|
||||
plsString.append(station.name)
|
||||
plsString.append("\n")
|
||||
plsString.append("File$counter=")
|
||||
plsString.append(station.getStreamUri())
|
||||
plsString.append("\n")
|
||||
plsString.append("Length$counter=-1")
|
||||
plsString.append("\n")
|
||||
counter++
|
||||
}
|
||||
|
||||
// add ending tag
|
||||
plsString.append("\n")
|
||||
plsString.append("NumberOfEntries=${collection.stations.size}")
|
||||
plsString.append("\n")
|
||||
plsString.append("Version=2")
|
||||
|
||||
return plsString.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Sends a broadcast containing the collection as parcel */
|
||||
fun sendCollectionBroadcast(context: Context, modificationDate: Date) {
|
||||
Log.v(TAG, "Broadcasting that collection has changed.")
|
||||
val collectionChangedIntent = Intent()
|
||||
collectionChangedIntent.action = Keys.ACTION_COLLECTION_CHANGED
|
||||
collectionChangedIntent.putExtra(
|
||||
Keys.EXTRA_COLLECTION_MODIFICATION_DATE,
|
||||
modificationDate.time
|
||||
)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(collectionChangedIntent)
|
||||
}
|
||||
|
||||
|
||||
// /* Creates MediaMetadata for a single station - used in media session*/
|
||||
// fun buildStationMediaMetadata(context: Context, station: Station, metadata: String): MediaMetadataCompat {
|
||||
// return MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ARTIST, station.name)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM, context.getString(R.string.app_name))
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, station.getStreamUri())
|
||||
// putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
|
||||
// //putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, station.image)
|
||||
// }.build()
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /* Creates MediaItem for a station - used by collection provider */
|
||||
// fun buildStationMediaMetaItem(context: Context, station: Station): MediaBrowserCompat.MediaItem {
|
||||
// val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
// mediaDescriptionBuilder.setMediaId(station.uuid)
|
||||
// mediaDescriptionBuilder.setTitle(station.name)
|
||||
// mediaDescriptionBuilder.setIconBitmap(ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
|
||||
// // mediaDescriptionBuilder.setIconUri(station.image.toUri())
|
||||
// return MediaBrowserCompat.MediaItem(mediaDescriptionBuilder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /* Creates description for a station - used in MediaSessionConnector */
|
||||
// fun buildStationMediaDescription(context: Context, station: Station, metadata: String): MediaDescriptionCompat {
|
||||
// val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN)
|
||||
// val extras: Bundle = Bundle()
|
||||
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, coverBitmap)
|
||||
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, coverBitmap)
|
||||
// return MediaDescriptionCompat.Builder().apply {
|
||||
// setMediaId(station.uuid)
|
||||
// setIconBitmap(coverBitmap)
|
||||
// setIconUri(station.image.toUri())
|
||||
// setTitle(metadata)
|
||||
// setSubtitle(station.name)
|
||||
// setExtras(extras)
|
||||
// }.build()
|
||||
// }
|
||||
|
||||
|
||||
/* Creates a MediaItem with MediaMetadata for a single radio station - used to prepare player */
|
||||
fun buildMediaItem(context: Context, station: Station): MediaItem {
|
||||
// todo implement HLS MediaItems
|
||||
// put uri in RequestMetadata - credit: https://stackoverflow.com/a/70103460
|
||||
val requestMetadata = MediaItem.RequestMetadata.Builder().apply {
|
||||
setMediaUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
// build MediaMetadata
|
||||
val mediaMetadata = MediaMetadata.Builder().apply {
|
||||
setArtist(station.name)
|
||||
//setTitle(station.name)
|
||||
/* check for "file://" prevents a crash when an old backup was restored */
|
||||
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
|
||||
//setArtworkUri(station.image.toUri())
|
||||
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
} else {
|
||||
//setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
|
||||
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
}
|
||||
setIsBrowsable(false)
|
||||
setIsPlayable(true)
|
||||
}.build()
|
||||
// build MediaItem and return it
|
||||
return MediaItem.Builder().apply {
|
||||
setMediaId(station.uuid)
|
||||
setRequestMetadata(requestMetadata)
|
||||
setMediaMetadata(mediaMetadata)
|
||||
//setMimeType(station.getMediaType())
|
||||
setUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
}
|
||||
|
||||
|
||||
/* Sorts radio stations */
|
||||
fun sortCollection(collection: Collection): Collection {
|
||||
val favoriteStations = collection.stations.filter { it.starred }
|
||||
val otherStations = collection.stations.filter { !it.starred }
|
||||
collection.stations = (favoriteStations + otherStations) as MutableList<Station>
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Get favicon address */
|
||||
private fun getFaviconAddress(urlString: String): String {
|
||||
var faviconAddress = String()
|
||||
try {
|
||||
var host: String = URL(urlString).host
|
||||
if (!host.startsWith("www")) {
|
||||
val index = host.indexOf(".")
|
||||
host = "www" + host.substring(index)
|
||||
}
|
||||
faviconAddress = "http://$host/favicon.ico"
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to get base URL from $urlString.\n$e ")
|
||||
}
|
||||
return faviconAddress
|
||||
}
|
||||
|
||||
}
|
||||
103
app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt
Normal file
103
app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* DateTimeHelper.kt
|
||||
* Implements the DateTimeHelper object
|
||||
* A DateTimeHelper provides helper methods for converting Date and Time objects
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* DateTimeHelper object
|
||||
*/
|
||||
object DateTimeHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = DateTimeHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private const val pattern: String = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
|
||||
|
||||
/* Converts RFC 2822 string representation of a date to DATE */
|
||||
fun convertFromRfc2822(dateString: String): Date {
|
||||
val date: Date = try {
|
||||
// parse date string using standard pattern
|
||||
dateFormat.parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e")
|
||||
// try alternative parsing patterns
|
||||
tryAlternativeRfc2822Parsing(dateString)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
/* Converts a DATE to its RFC 2822 string representation */
|
||||
fun convertToRfc2822(date: Date): String {
|
||||
val dateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
return dateFormat.format(date)
|
||||
}
|
||||
|
||||
|
||||
/* Converts a milliseconds into a readable format (HH:mm:ss) */
|
||||
fun convertToHoursMinutesSeconds(milliseconds: Long, negativeValue: Boolean = false): String {
|
||||
// convert milliseconds to hours, minutes, and seconds
|
||||
val hours: Long = milliseconds / 1000 / 3600
|
||||
val minutes: Long = milliseconds / 1000 % 3600 / 60
|
||||
val seconds: Long = milliseconds / 1000 % 60
|
||||
val hourPart = if (hours > 0) {
|
||||
"${hours.toString().padStart(2, '0')}:"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
var timeString =
|
||||
"$hourPart${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
if (negativeValue) {
|
||||
// add a minus sign if a negative values was requested
|
||||
timeString = "-$timeString"
|
||||
}
|
||||
return timeString
|
||||
}
|
||||
|
||||
|
||||
/* Converts RFC 2822 string representation of a date to DATE - using alternative patterns */
|
||||
private fun tryAlternativeRfc2822Parsing(dateString: String): Date {
|
||||
var date: Date = Keys.DEFAULT_DATE
|
||||
try {
|
||||
// try to parse without seconds
|
||||
date = SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.ENGLISH).parse((dateString))
|
||||
?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e")
|
||||
// try to parse without time zone
|
||||
date = SimpleDateFormat(
|
||||
"EEE, dd MMM yyyy HH:mm:ss",
|
||||
Locale.ENGLISH
|
||||
).parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to parse. Returning a default date. $e")
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* DownloadFinishedReceiver.kt
|
||||
* Implements the DownloadFinishedReceiver class
|
||||
* A DownloadFinishedReceiver listens for finished downloads
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
|
||||
/*
|
||||
* DownloadFinishedReceiver class
|
||||
*/
|
||||
class DownloadFinishedReceiver : BroadcastReceiver() {
|
||||
|
||||
/* Overrides onReceive */
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// process the finished download
|
||||
DownloadHelper.processDownload(
|
||||
context,
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
||||
)
|
||||
}
|
||||
}
|
||||
396
app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt
Normal file
396
app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt
Normal file
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* DownloadHelper.kt
|
||||
* Implements the DownloadHelper object
|
||||
* A DownloadHelper provides helper methods for downloading files
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.extensions.copy
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* DownloadHelper object
|
||||
*/
|
||||
object DownloadHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = DownloadHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var collection: Collection
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var activeDownloads: ArrayList<Long>
|
||||
private lateinit var modificationDate: Date
|
||||
|
||||
|
||||
/* Download station playlists */
|
||||
fun downloadPlaylists(context: Context, playlistUrlStrings: Array<String>) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// convert array
|
||||
val uris: Array<Uri> =
|
||||
Array(playlistUrlStrings.size) { index -> playlistUrlStrings[index].toUri() }
|
||||
// enqueue playlists
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_PLAYLIST)
|
||||
}
|
||||
|
||||
|
||||
/* Refresh image of given station */
|
||||
fun updateStationImage(context: Context, station: Station) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// check if station has an image reference
|
||||
if (station.remoteImageLocation.isNotEmpty()) {
|
||||
CollectionHelper.clearImagesFolder(context, station)
|
||||
val uris: Array<Uri> = Array(1) { station.remoteImageLocation.toUri() }
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_IMAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates all station images */
|
||||
fun updateStationImages(context: Context) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// re-download all station images
|
||||
PreferencesHelper.saveLastUpdateCollection()
|
||||
val uris: MutableList<Uri> = mutableListOf()
|
||||
collection.stations.forEach { station ->
|
||||
station.radioBrowserStationUuid
|
||||
if (!station.imageManuallySet) {
|
||||
uris.add(station.remoteImageLocation.toUri())
|
||||
}
|
||||
}
|
||||
enqueueDownload(context, uris.toTypedArray(), Keys.FILE_TYPE_IMAGE)
|
||||
Log.i(TAG, "Updating all station images.")
|
||||
}
|
||||
|
||||
|
||||
/* Processes a given download ID */
|
||||
fun processDownload(context: Context, downloadId: Long) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// get local Uri in content://downloads/all_downloads/ for download ID
|
||||
val downloadResult: Uri? = downloadManager.getUriForDownloadedFile(downloadId)
|
||||
if (downloadResult == null) {
|
||||
val downloadErrorCode: Int = getDownloadError(downloadId)
|
||||
val downloadErrorFileName: String = getDownloadFileName(downloadManager, downloadId)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${context.getString(R.string.toastmessage_error_download_error)}: $downloadErrorFileName ($downloadErrorCode)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Log.w(
|
||||
TAG,
|
||||
"Download not successful: File name = $downloadErrorFileName Error code = $downloadErrorCode"
|
||||
)
|
||||
removeFromActiveDownloads(arrayOf(downloadId), deleteDownload = true)
|
||||
return
|
||||
} else {
|
||||
val localFileUri: Uri = downloadResult
|
||||
// get remote URL for download ID
|
||||
val remoteFileLocation: String = getRemoteFileLocation(downloadManager, downloadId)
|
||||
// determine what to do
|
||||
val fileType = FileHelper.getContentType(context, localFileUri)
|
||||
if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && CollectionHelper.isNewStation(
|
||||
collection,
|
||||
remoteFileLocation
|
||||
)
|
||||
) {
|
||||
addStation(context, localFileUri, remoteFileLocation)
|
||||
} else if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && !CollectionHelper.isNewStation(
|
||||
collection,
|
||||
remoteFileLocation
|
||||
)
|
||||
) {
|
||||
updateStation(context, localFileUri, remoteFileLocation)
|
||||
} else if (fileType in Keys.MIME_TYPES_IMAGE) {
|
||||
collection = CollectionHelper.setStationImageWithRemoteLocation(
|
||||
context,
|
||||
collection,
|
||||
localFileUri.toString(),
|
||||
remoteFileLocation,
|
||||
false
|
||||
)
|
||||
} else if (fileType in Keys.MIME_TYPES_FAVICON) {
|
||||
collection = CollectionHelper.setStationImageWithRemoteLocation(
|
||||
context,
|
||||
collection,
|
||||
localFileUri.toString(),
|
||||
remoteFileLocation,
|
||||
false
|
||||
)
|
||||
}
|
||||
// remove ID from active downloads
|
||||
removeFromActiveDownloads(arrayOf(downloadId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Initializes main class variables of DownloadHelper, if necessary */
|
||||
private fun initialize(context: Context) {
|
||||
if (!this::modificationDate.isInitialized) {
|
||||
modificationDate = PreferencesHelper.loadCollectionModificationDate()
|
||||
}
|
||||
if (!this::collection.isInitialized || CollectionHelper.isNewerCollectionAvailable(
|
||||
modificationDate
|
||||
)
|
||||
) {
|
||||
collection = FileHelper.readCollection(context)
|
||||
modificationDate = PreferencesHelper.loadCollectionModificationDate()
|
||||
}
|
||||
if (!this::downloadManager.isInitialized) {
|
||||
FileHelper.clearFolder(context.getExternalFilesDir(Keys.FOLDER_TEMP), 0)
|
||||
downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
}
|
||||
if (!this::activeDownloads.isInitialized) {
|
||||
activeDownloads = getActiveDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Enqueues an Array of files in DownloadManager */
|
||||
private fun enqueueDownload(
|
||||
context: Context,
|
||||
uris: Array<Uri>,
|
||||
type: Int,
|
||||
ignoreWifiRestriction: Boolean = false
|
||||
) {
|
||||
// determine allowed network types
|
||||
val allowedNetworkTypes: Int = determineAllowedNetworkTypes(type, ignoreWifiRestriction)
|
||||
// enqueue downloads
|
||||
val newIds = LongArray(uris.size)
|
||||
for (i in uris.indices) {
|
||||
Log.v(TAG, "DownloadManager enqueue: ${uris[i]}")
|
||||
// check if valid url and prevent double download
|
||||
val uri: Uri = uris[i]
|
||||
val scheme: String = uri.scheme ?: String()
|
||||
val pathSegments: List<String> = uri.pathSegments
|
||||
if (scheme.startsWith("http") && isNotInDownloadQueue(uri.toString()) && pathSegments.isNotEmpty()) {
|
||||
val fileName: String = pathSegments.last()
|
||||
val request: DownloadManager.Request = DownloadManager.Request(uri)
|
||||
.setAllowedNetworkTypes(allowedNetworkTypes)
|
||||
.setTitle(fileName)
|
||||
.setDestinationInExternalFilesDir(context, Keys.FOLDER_TEMP, fileName)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
newIds[i] = downloadManager.enqueue(request)
|
||||
activeDownloads.add(newIds[i])
|
||||
}
|
||||
}
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a file is not yet in download queue */
|
||||
private fun isNotInDownloadQueue(remoteFileLocation: String): Boolean {
|
||||
val activeDownloadsCopy = activeDownloads.copy()
|
||||
activeDownloadsCopy.forEach { downloadId ->
|
||||
if (getRemoteFileLocation(downloadManager, downloadId) == remoteFileLocation) {
|
||||
Log.w(TAG, "File is already in download queue: $remoteFileLocation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
Log.v(TAG, "File is not in download queue.")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Safely remove given download IDs from activeDownloads and delete download if requested */
|
||||
private fun removeFromActiveDownloads(
|
||||
downloadIds: Array<Long>,
|
||||
deleteDownload: Boolean = false
|
||||
): Boolean {
|
||||
// remove download ids from activeDownloads
|
||||
val success: Boolean =
|
||||
activeDownloads.removeAll { downloadId -> downloadIds.contains(downloadId) }
|
||||
if (success) {
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
// optionally: delete download
|
||||
if (deleteDownload) {
|
||||
downloadIds.forEach { downloadId -> downloadManager.remove(downloadId) }
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
|
||||
/* Reads station playlist file and adds it to collection */
|
||||
private fun addStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
// read station playlist
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// set content type
|
||||
station.streamContent = contentType.type
|
||||
// add station and save collection
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.addStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads station playlist file and updates it in collection */
|
||||
private fun updateStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
// read station playlist
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// update content type
|
||||
station.streamContent = contentType.type
|
||||
// update station and save collection
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves active downloads (IntArray) to shared preferences */
|
||||
private fun setActiveDownloads(activeDownloads: ArrayList<Long>) {
|
||||
val builder = StringBuilder()
|
||||
for (i in activeDownloads.indices) {
|
||||
builder.append(activeDownloads[i]).append(",")
|
||||
}
|
||||
var activeDownloadsString: String = builder.toString()
|
||||
if (activeDownloadsString.isEmpty()) {
|
||||
activeDownloadsString = Keys.ACTIVE_DOWNLOADS_EMPTY
|
||||
}
|
||||
PreferencesHelper.saveActiveDownloads(activeDownloadsString)
|
||||
}
|
||||
|
||||
|
||||
/* Loads active downloads (IntArray) from shared preferences */
|
||||
private fun getActiveDownloads(): ArrayList<Long> {
|
||||
var inactiveDownloadsFound = false
|
||||
val activeDownloadsList: ArrayList<Long> = arrayListOf()
|
||||
val activeDownloadsString: String = PreferencesHelper.loadActiveDownloads()
|
||||
val count = activeDownloadsString.split(",").size - 1
|
||||
val tokenizer = StringTokenizer(activeDownloadsString, ",")
|
||||
repeat(count) {
|
||||
val token = tokenizer.nextToken().toLong()
|
||||
when (isDownloadActive(token)) {
|
||||
true -> activeDownloadsList.add(token)
|
||||
false -> inactiveDownloadsFound = true
|
||||
}
|
||||
}
|
||||
if (inactiveDownloadsFound) setActiveDownloads(activeDownloadsList)
|
||||
return activeDownloadsList
|
||||
}
|
||||
|
||||
|
||||
/* Determines the remote file location (the original URL) */
|
||||
private fun getRemoteFileLocation(downloadManager: DownloadManager, downloadId: Long): String {
|
||||
var remoteFileLocation = ""
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
remoteFileLocation =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI))
|
||||
}
|
||||
return remoteFileLocation
|
||||
}
|
||||
|
||||
|
||||
/* Determines the file name for given download id (the original URL) */
|
||||
private fun getDownloadFileName(downloadManager: DownloadManager, downloadId: Long): String {
|
||||
var remoteFileLocation = ""
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
remoteFileLocation =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
|
||||
}
|
||||
return remoteFileLocation
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a given download ID represents a finished download */
|
||||
private fun isDownloadActive(downloadId: Long): Boolean {
|
||||
var downloadStatus: Int = -1
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
downloadStatus =
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
return downloadStatus == DownloadManager.STATUS_RUNNING
|
||||
}
|
||||
|
||||
|
||||
/* Retrieves reason of download error - returns http error codes plus error codes found here check: https://developer.android.com/reference/android/app/DownloadManager */
|
||||
private fun getDownloadError(downloadId: Long): Int {
|
||||
var reason: Int = -1
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
val downloadStatus =
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
reason = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||
}
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
|
||||
/* Determine allowed network type */
|
||||
private fun determineAllowedNetworkTypes(type: Int, ignoreWifiRestriction: Boolean): Int {
|
||||
var allowedNetworkTypes: Int =
|
||||
(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
|
||||
// restrict download of audio files to WiFi if necessary
|
||||
if (type == Keys.FILE_TYPE_AUDIO) {
|
||||
if (!ignoreWifiRestriction && !PreferencesHelper.downloadOverMobile()) {
|
||||
allowedNetworkTypes = DownloadManager.Request.NETWORK_WIFI
|
||||
}
|
||||
}
|
||||
return allowedNetworkTypes
|
||||
}
|
||||
|
||||
}
|
||||
497
app/src/main/java/com/michatec/radio/helpers/FileHelper.kt
Normal file
497
app/src/main/java/com/michatec/radio/helpers/FileHelper.kt
Normal file
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
* FileHelper.kt
|
||||
* Implements the FileHelper object
|
||||
* A FileHelper provides helper methods for reading and writing files from and to device storage
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
/*
|
||||
* FileHelper object
|
||||
*/
|
||||
object FileHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = FileHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Get file size for given Uri */
|
||||
fun getFileSize(context: Context, uri: Uri): Long {
|
||||
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
||||
return if (cursor != null) {
|
||||
val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
val size: Long = cursor.getLong(sizeIndex)
|
||||
cursor.close()
|
||||
size
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get file name for given Uri */
|
||||
fun getFileName(context: Context, uri: Uri): String {
|
||||
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
||||
return if (cursor != null) {
|
||||
val nameIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
val name: String = cursor.getString(nameIndex)
|
||||
cursor.close()
|
||||
name
|
||||
} else {
|
||||
String()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get content type for given file */
|
||||
fun getContentType(context: Context, uri: Uri): String {
|
||||
// get file type from content resolver
|
||||
var contentType: String = context.contentResolver.getType(uri) ?: Keys.MIME_TYPE_UNSUPPORTED
|
||||
contentType = contentType.lowercase(Locale.getDefault())
|
||||
return if (contentType != Keys.MIME_TYPE_UNSUPPORTED && !contentType.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
||||
// return the found content type
|
||||
contentType
|
||||
} else {
|
||||
// fallback: try to determine file type based on file extension
|
||||
getContentTypeFromExtension(getFileName(context, uri))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Determine content type based on file extension */
|
||||
fun getContentTypeFromExtension(fileName: String): String {
|
||||
Log.i(TAG, "Deducing content type from file name: $fileName")
|
||||
if (fileName.endsWith("m3u", true)) return Keys.MIME_TYPE_M3U
|
||||
if (fileName.endsWith("pls", true)) return Keys.MIME_TYPE_PLS
|
||||
if (fileName.endsWith("png", true)) return Keys.MIME_TYPE_PNG
|
||||
if (fileName.endsWith("jpg", true)) return Keys.MIME_TYPE_JPG
|
||||
if (fileName.endsWith("jpeg", true)) return Keys.MIME_TYPE_JPG
|
||||
// default return
|
||||
return Keys.MIME_TYPE_UNSUPPORTED
|
||||
}
|
||||
|
||||
|
||||
/* Determines a destination folder */
|
||||
fun determineDestinationFolderPath(type: Int, stationUuid: String): String {
|
||||
val folderPath: String = when (type) {
|
||||
Keys.FILE_TYPE_PLAYLIST -> Keys.FOLDER_TEMP
|
||||
Keys.FILE_TYPE_AUDIO -> Keys.FOLDER_AUDIO + "/" + stationUuid
|
||||
Keys.FILE_TYPE_IMAGE -> Keys.FOLDER_IMAGES + "/" + stationUuid
|
||||
else -> "/"
|
||||
}
|
||||
return folderPath
|
||||
}
|
||||
|
||||
|
||||
/* Clears given folder - keeps given number of files */
|
||||
fun clearFolder(folder: File?, keep: Int, deleteFolder: Boolean = false) {
|
||||
if (folder != null && folder.exists()) {
|
||||
val files = folder.listFiles()!!
|
||||
val fileCount: Int = files.size
|
||||
files.sortBy { it.lastModified() }
|
||||
for (fileNumber in files.indices) {
|
||||
if (fileNumber < fileCount - keep) {
|
||||
files[fileNumber].delete()
|
||||
}
|
||||
}
|
||||
if (deleteFolder && keep == 0) {
|
||||
folder.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates and save a scaled version of the station image */
|
||||
fun saveStationImage(
|
||||
context: Context,
|
||||
stationUuid: String,
|
||||
sourceImageUri: Uri,
|
||||
size: Int,
|
||||
fileName: String
|
||||
): Uri {
|
||||
val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, sourceImageUri, size)
|
||||
val file = File(
|
||||
context.getExternalFilesDir(
|
||||
determineDestinationFolderPath(
|
||||
Keys.FILE_TYPE_IMAGE,
|
||||
stationUuid
|
||||
)
|
||||
), fileName
|
||||
)
|
||||
writeImageFile(coverBitmap, file)
|
||||
return file.toUri()
|
||||
}
|
||||
|
||||
|
||||
/* Saves collection of radio stations as JSON text file */
|
||||
fun saveCollection(context: Context, collection: Collection, lastSave: Date) {
|
||||
Log.v(TAG, "Saving collection - Thread: ${Thread.currentThread().name}")
|
||||
val collectionSize: Int = collection.stations.size
|
||||
// do not override an existing collection with an empty one - except when last station is deleted
|
||||
if (collectionSize > 0 || PreferencesHelper.loadCollectionSize() == 1) {
|
||||
// convert to JSON
|
||||
val gson: Gson = getCustomGson()
|
||||
var json = String()
|
||||
try {
|
||||
json = gson.toJson(collection)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (json.isNotBlank()) {
|
||||
// write text file
|
||||
writeTextFile(context, json, Keys.FOLDER_COLLECTION, Keys.COLLECTION_FILE)
|
||||
// save modification date and collection size
|
||||
PreferencesHelper.saveCollectionModificationDate(lastSave)
|
||||
PreferencesHelper.saveCollectionSize(collectionSize)
|
||||
} else {
|
||||
Log.w(TAG, "Not writing collection file. Reason: JSON string was completely empty.")
|
||||
}
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Not saving collection. Reason: Trying to override an collection with more than one station"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads m3u or pls playlists */
|
||||
fun readStationPlaylist(playlistInputStream: InputStream?): Station {
|
||||
val station = Station()
|
||||
if (playlistInputStream != null) {
|
||||
val reader = BufferedReader(InputStreamReader(playlistInputStream))
|
||||
// until last line reached: read station name and stream address(es)
|
||||
reader.forEachLine { line ->
|
||||
when {
|
||||
// M3U: found station name
|
||||
line.contains("#EXTINF:-1,") -> station.name = line.substring(11).trim()
|
||||
line.contains("#EXTINF:0,") -> station.name = line.substring(10).trim()
|
||||
// M3U: found stream URL
|
||||
line.startsWith("http") -> station.streamUris.add(0, line.trim())
|
||||
// PLS: found station name
|
||||
line.matches(Regex("^Title[0-9]+=.*")) -> station.name =
|
||||
line.substring(line.indexOf("=") + 1).trim()
|
||||
// PLS: found stream URL
|
||||
line.matches(Regex("^File[0-9]+=http.*")) -> station.streamUris.add(
|
||||
line.substring(
|
||||
line.indexOf("=") + 1
|
||||
).trim()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
playlistInputStream.close()
|
||||
}
|
||||
return station
|
||||
}
|
||||
|
||||
|
||||
/* Reads collection of radio stations from storage using GSON */
|
||||
fun readCollection(context: Context): Collection {
|
||||
Log.v(TAG, "Reading collection - Thread: ${Thread.currentThread().name}")
|
||||
// get JSON from text file
|
||||
val json: String = readTextFileFromFile(context)
|
||||
var collection = Collection()
|
||||
if (json.isNotBlank()) {
|
||||
// convert JSON and return as collection
|
||||
try {
|
||||
collection = getCustomGson().fromJson(json, collection::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error Reading collection.\nContent: $json")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Get content Uri for M3U file */
|
||||
fun getM3ulUri(activity: Activity): Uri? {
|
||||
var m3ulUri: Uri? = null
|
||||
// try to get an existing M3U File
|
||||
var m3uFile =
|
||||
File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_M3U_FILE)
|
||||
if (!m3uFile.exists()) {
|
||||
m3uFile = File(
|
||||
activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION),
|
||||
Keys.COLLECTION_M3U_FILE
|
||||
)
|
||||
}
|
||||
// get Uri for existing M3U File
|
||||
if (m3uFile.exists()) {
|
||||
m3ulUri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.applicationContext.packageName}.provider",
|
||||
m3uFile
|
||||
)
|
||||
}
|
||||
return m3ulUri
|
||||
}
|
||||
|
||||
|
||||
/* Get content Uri for PLS file */
|
||||
fun getPlslUri(activity: Activity): Uri? {
|
||||
var plslUri: Uri? = null
|
||||
// try to get an existing PLS File
|
||||
var plsFile =
|
||||
File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_PLS_FILE)
|
||||
if (!plsFile.exists()) {
|
||||
plsFile = File(
|
||||
activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION),
|
||||
Keys.COLLECTION_PLS_FILE
|
||||
)
|
||||
}
|
||||
// get Uri for existing M3U File
|
||||
if (plsFile.exists()) {
|
||||
plslUri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.applicationContext.packageName}.provider",
|
||||
plsFile
|
||||
)
|
||||
}
|
||||
return plslUri
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for saveCollection */
|
||||
suspend fun saveCollectionSuspended(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
lastUpdate: Date
|
||||
) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(saveCollection(context, collection, lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for readCollection */
|
||||
suspend fun readCollectionSuspended(context: Context): Collection =
|
||||
withContext(IO) {
|
||||
readCollection(context)
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for copyFile */
|
||||
suspend fun saveCopyOfFileSuspended(
|
||||
context: Context,
|
||||
originalFileUri: Uri,
|
||||
targetFileUri: Uri
|
||||
): Boolean {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(copyFile(context, originalFileUri, targetFileUri))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Exports collection of stations as M3U file - local backup copy */
|
||||
suspend fun backupCollectionAsM3uSuspended(context: Context, collection: Collection) {
|
||||
return suspendCoroutine { cont ->
|
||||
Log.v(TAG, "Backing up collection as M3U - Thread: ${Thread.currentThread().name}")
|
||||
// create M3U string
|
||||
val m3uString: String = CollectionHelper.createM3uString(collection)
|
||||
// save M3U as text file
|
||||
cont.resume(
|
||||
writeTextFile(
|
||||
context,
|
||||
m3uString,
|
||||
Keys.FOLDER_COLLECTION,
|
||||
Keys.COLLECTION_M3U_FILE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Exports collection of stations as PLS file - local backup copy */
|
||||
suspend fun backupCollectionAsPlsSuspended(context: Context, collection: Collection) {
|
||||
return suspendCoroutine { cont ->
|
||||
Log.v(TAG, "Backing up collection as PLS - Thread: ${Thread.currentThread().name}")
|
||||
// create PLS string
|
||||
val plsString: String = CollectionHelper.createPlsString(collection)
|
||||
// save PLS as text file
|
||||
cont.resume(
|
||||
writeTextFile(
|
||||
context,
|
||||
plsString,
|
||||
Keys.FOLDER_COLLECTION,
|
||||
Keys.COLLECTION_PLS_FILE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Copies file to specified target */
|
||||
private fun copyFile(
|
||||
context: Context,
|
||||
originalFileUri: Uri,
|
||||
targetFileUri: Uri,
|
||||
): Boolean {
|
||||
var success = true
|
||||
var inputStream: InputStream? = null
|
||||
val outputStream: OutputStream?
|
||||
try {
|
||||
inputStream = context.contentResolver.openInputStream(originalFileUri)
|
||||
outputStream = context.contentResolver.openOutputStream(targetFileUri)
|
||||
if (outputStream != null && inputStream != null) {
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream.close() // Close the output stream after copying
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to copy file.")
|
||||
success = false
|
||||
exception.printStackTrace()
|
||||
} finally {
|
||||
inputStream?.close() // Close the input stream in the finally block
|
||||
}
|
||||
if (success) {
|
||||
try {
|
||||
// use contentResolver to handle files of type content://
|
||||
context.contentResolver.delete(originalFileUri, null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to delete the original file. Stack trace: $e")
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
|
||||
/* Creates a Gson object */
|
||||
private fun getCustomGson(): Gson {
|
||||
val gsonBuilder = GsonBuilder()
|
||||
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
|
||||
gsonBuilder.excludeFieldsWithoutExposeAnnotation()
|
||||
return gsonBuilder.create()
|
||||
}
|
||||
|
||||
|
||||
/* Create nomedia file in given folder to prevent media scanning */
|
||||
fun createNomediaFile(folder: File?) {
|
||||
if (folder != null && folder.exists() && folder.isDirectory) {
|
||||
val nomediaFile: File = getNoMediaFile(folder)
|
||||
if (!nomediaFile.exists()) {
|
||||
val noMediaOutStream = FileOutputStream(getNoMediaFile(folder))
|
||||
noMediaOutStream.write(0)
|
||||
} else {
|
||||
Log.v(TAG, ".nomedia file exists already in given folder.")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Unable to create .nomedia file. Given folder is not valid.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads InputStream from file uri and returns it as String */
|
||||
private fun readTextFileFromFile(context: Context): String {
|
||||
// todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html
|
||||
// https://developer.android.com/training/secure-file-sharing/retrieve-info
|
||||
|
||||
// check if file exists
|
||||
val file = File(context.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_FILE)
|
||||
if (!file.exists() || !file.canRead()) {
|
||||
return String()
|
||||
}
|
||||
// read until last line reached
|
||||
val stream: InputStream = file.inputStream()
|
||||
val reader = BufferedReader(InputStreamReader(stream))
|
||||
val builder: StringBuilder = StringBuilder()
|
||||
reader.forEachLine {
|
||||
builder.append(it)
|
||||
builder.append("\n")
|
||||
}
|
||||
stream.close()
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Reads InputStream from content uri and returns it as List of String */
|
||||
fun readTextFileFromContentUri(context: Context, contentUri: Uri): List<String> {
|
||||
val lines: MutableList<String> = mutableListOf()
|
||||
try {
|
||||
// open input stream from content URI
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(contentUri)
|
||||
if (inputStream != null) {
|
||||
val reader: InputStreamReader = inputStream.reader()
|
||||
var index = 0
|
||||
reader.forEachLine {
|
||||
index += 1
|
||||
if (index < 256)
|
||||
lines.add(it)
|
||||
}
|
||||
inputStream.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
/* Writes given text to file on storage */
|
||||
@Suppress("SameParameterValue")
|
||||
private fun writeTextFile(context: Context, text: String, folder: String, fileName: String) {
|
||||
if (text.isNotBlank()) {
|
||||
File(context.getExternalFilesDir(folder), fileName).writeText(text)
|
||||
} else {
|
||||
Log.w(TAG, "Writing text file $fileName failed. Empty text string text was provided.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Writes given bitmap as image file to storage */
|
||||
private fun writeImageFile(
|
||||
bitmap: Bitmap,
|
||||
file: File
|
||||
) {
|
||||
if (file.exists()) file.delete()
|
||||
try {
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 75, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Returns a nomedia file object */
|
||||
private fun getNoMediaFile(folder: File): File {
|
||||
return File(folder, ".nomedia")
|
||||
}
|
||||
|
||||
}
|
||||
257
app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt
Normal file
257
app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* ImageHelper.kt
|
||||
* Implements the ImageHelper object
|
||||
* An ImageHelper provides helper methods for image related operations
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.palette.graphics.Palette
|
||||
import com.michatec.radio.R
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
/*
|
||||
* ImageHelper class
|
||||
*/
|
||||
object ImageHelper {
|
||||
|
||||
/* Get scaling factor from display density */
|
||||
fun getDensityScalingFactor(context: Context): Float {
|
||||
return context.resources.displayMetrics.density
|
||||
}
|
||||
|
||||
|
||||
/* Get a scaled version of the station image */
|
||||
fun getScaledStationImage(context: Context, imageUri: Uri, imageSize: Int): Bitmap {
|
||||
val size: Int = (imageSize * getDensityScalingFactor(context)).toInt()
|
||||
return decodeSampledBitmapFromUri(context, imageUri, size, size)
|
||||
}
|
||||
|
||||
|
||||
/* Get an unscaled version of the station image */
|
||||
fun getStationImage(context: Context, imageUriString: String): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
|
||||
if (imageUriString.isNotEmpty()) {
|
||||
try {
|
||||
// just decode the file
|
||||
bitmap = BitmapFactory.decodeFile(imageUriString.toUri().path)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// get default image
|
||||
if (bitmap == null) {
|
||||
bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!
|
||||
.toBitmap()
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
|
||||
/* Get an unscaled version of the station image as a ByteArray */
|
||||
fun getStationImageAsByteArray(context: Context, imageUriString: String = String()): ByteArray {
|
||||
val coverBitmap: Bitmap = getStationImage(context, imageUriString)
|
||||
val stream = ByteArrayOutputStream()
|
||||
coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
val coverByteArray: ByteArray = stream.toByteArray()
|
||||
coverBitmap.recycle()
|
||||
return coverByteArray
|
||||
}
|
||||
|
||||
|
||||
/* Creates station image on a square background with the main station image color and option padding for adaptive icons */
|
||||
fun createSquareImage(
|
||||
context: Context,
|
||||
bitmap: Bitmap,
|
||||
backgroundColor: Int,
|
||||
size: Int,
|
||||
adaptivePadding: Boolean
|
||||
): Bitmap {
|
||||
|
||||
// create background
|
||||
val background = Paint()
|
||||
background.style = Paint.Style.FILL
|
||||
if (backgroundColor != -1) {
|
||||
background.color = backgroundColor
|
||||
} else {
|
||||
background.color = ContextCompat.getColor(context, R.color.default_neutral_dark)
|
||||
}
|
||||
|
||||
// create empty bitmap and canvas
|
||||
val outputImage: Bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val imageCanvas = Canvas(outputImage)
|
||||
|
||||
// draw square background
|
||||
val right = size.toFloat()
|
||||
val bottom = size.toFloat()
|
||||
imageCanvas.drawRect(0f, 0f, right, bottom, background)
|
||||
|
||||
// draw input image onto canvas using transformation matrix
|
||||
val paint = Paint()
|
||||
paint.isFilterBitmap = true
|
||||
imageCanvas.drawBitmap(
|
||||
bitmap,
|
||||
createTransformationMatrix(
|
||||
size,
|
||||
bitmap.height.toFloat(),
|
||||
bitmap.width.toFloat(),
|
||||
adaptivePadding
|
||||
),
|
||||
paint
|
||||
)
|
||||
return outputImage
|
||||
}
|
||||
|
||||
|
||||
/* Extracts color from an image */
|
||||
fun getMainColor(context: Context, imageUri: Uri): Int {
|
||||
// extract color palette from station image
|
||||
val palette: Palette =
|
||||
Palette.from(decodeSampledBitmapFromUri(context, imageUri, 72, 72)).generate()
|
||||
// get muted and vibrant swatches
|
||||
val vibrantSwatch = palette.vibrantSwatch
|
||||
val mutedSwatch = palette.mutedSwatch
|
||||
|
||||
when {
|
||||
vibrantSwatch != null -> {
|
||||
// return vibrant color
|
||||
val rgb = vibrantSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
mutedSwatch != null -> {
|
||||
// return muted color
|
||||
val rgb = mutedSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
else -> {
|
||||
// default return
|
||||
return context.resources.getColor(R.color.default_neutral_medium_light, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Return sampled down image for given Uri */
|
||||
private fun decodeSampledBitmapFromUri(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
if (imageUri.toString().isNotEmpty()) {
|
||||
try {
|
||||
// first decode with inJustDecodeBounds=true to check dimensions
|
||||
var stream: InputStream? = context.contentResolver.openInputStream(imageUri)
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
|
||||
// calculate inSampleSize
|
||||
options.inSampleSize = calculateSampleParameter(options, reqWidth, reqHeight)
|
||||
|
||||
// decode bitmap with inSampleSize set
|
||||
stream = context.contentResolver.openInputStream(imageUri)
|
||||
options.inJustDecodeBounds = false
|
||||
bitmap = BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// get default image
|
||||
if (bitmap == null) {
|
||||
bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!
|
||||
.toBitmap()
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
|
||||
/* Calculates parameter needed to scale image down */
|
||||
private fun calculateSampleParameter(
|
||||
options: BitmapFactory.Options,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Int {
|
||||
// get size of original image
|
||||
val height = options.outHeight
|
||||
val width = options.outWidth
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
|
||||
val halfHeight = height / 2
|
||||
val halfWidth = width / 2
|
||||
|
||||
// calculates the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width
|
||||
while (halfHeight / inSampleSize > reqHeight && halfWidth / inSampleSize > reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
return inSampleSize
|
||||
}
|
||||
|
||||
|
||||
/* Creates a transformation matrix with the given size and optional padding */
|
||||
private fun createTransformationMatrix(
|
||||
size: Int,
|
||||
inputImageHeight: Float,
|
||||
inputImageWidth: Float,
|
||||
scaled: Boolean
|
||||
): Matrix {
|
||||
val matrix = Matrix()
|
||||
|
||||
// calculate padding
|
||||
var padding = 0f
|
||||
if (scaled) {
|
||||
padding = size.toFloat() / 4f
|
||||
}
|
||||
|
||||
// define variables needed for transformation matrix
|
||||
val aspectRatio: Float
|
||||
val xTranslation: Float
|
||||
val yTranslation: Float
|
||||
|
||||
// landscape format and square
|
||||
if (inputImageWidth >= inputImageHeight) {
|
||||
aspectRatio = (size - padding * 2) / inputImageWidth
|
||||
xTranslation = 0.0f + padding
|
||||
yTranslation = (size - inputImageHeight * aspectRatio) / 2.0f
|
||||
} else {
|
||||
aspectRatio = (size - padding * 2) / inputImageHeight
|
||||
yTranslation = 0.0f + padding
|
||||
xTranslation = (size - inputImageWidth * aspectRatio) / 2.0f
|
||||
}
|
||||
|
||||
// construct transformation matrix
|
||||
matrix.postTranslate(xTranslation, yTranslation)
|
||||
matrix.preScale(aspectRatio, aspectRatio)
|
||||
return matrix
|
||||
}
|
||||
|
||||
}
|
||||
42
app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt
Normal file
42
app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* ImportHelper.kt
|
||||
* Implements the ImportHelper object
|
||||
* A ImportHelper provides methods for integrating station files from Radio v3
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
|
||||
|
||||
/*
|
||||
* ImportHelper object
|
||||
*/
|
||||
object ImportHelper {
|
||||
|
||||
|
||||
/* */
|
||||
fun removeDefaultStationImageUris(context: Context) {
|
||||
val collection: Collection = FileHelper.readCollection(context)
|
||||
collection.stations.forEach { station ->
|
||||
if (station.image == Keys.LOCATION_DEFAULT_STATION_IMAGE) {
|
||||
station.image = String()
|
||||
}
|
||||
if (station.smallImage == Keys.LOCATION_DEFAULT_STATION_IMAGE) {
|
||||
station.smallImage = String()
|
||||
}
|
||||
}
|
||||
CollectionHelper.saveCollection(context, collection, async = false)
|
||||
}
|
||||
|
||||
}
|
||||
167
app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt
Normal file
167
app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* NetworkHelper.kt
|
||||
* Implements the NetworkHelper object
|
||||
* A NetworkHelper provides helper methods for network related operations
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetAddress
|
||||
import java.net.URL
|
||||
import java.net.UnknownHostException
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
/*
|
||||
* NetworkHelper object
|
||||
*/
|
||||
object NetworkHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = NetworkHelper::class.java.simpleName
|
||||
|
||||
/* Data class: holder for content type information */
|
||||
data class ContentType(var type: String = String(), var charset: String = String())
|
||||
|
||||
|
||||
/* Checks if the active network connection is connected to any network */
|
||||
fun isConnectedToNetwork(context: Context): Boolean {
|
||||
val connMgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetwork: Network? = connMgr.activeNetwork
|
||||
return activeNetwork != null
|
||||
}
|
||||
|
||||
|
||||
/* Detects content type (mime type) from given URL string - async using coroutine - use only on separate threat */
|
||||
fun detectContentType(urlString: String): ContentType {
|
||||
Log.v(TAG, "Determining content type - Thread: ${Thread.currentThread().name}")
|
||||
val contentType = ContentType(Keys.MIME_TYPE_UNSUPPORTED, Keys.CHARSET_UNDEFINDED)
|
||||
val connection: HttpURLConnection? = createConnection(urlString)
|
||||
if (connection != null) {
|
||||
val contentTypeHeader: String = connection.contentType ?: String()
|
||||
Log.v(TAG, "Raw content type header: $contentTypeHeader")
|
||||
val contentTypeHeaderParts: List<String> = contentTypeHeader.split(";")
|
||||
contentTypeHeaderParts.forEachIndexed { index, part ->
|
||||
if (index == 0 && part.isNotEmpty()) {
|
||||
contentType.type = part.trim()
|
||||
} else if (part.contains("charset=")) {
|
||||
contentType.charset = part.substringAfter("charset=").trim()
|
||||
}
|
||||
}
|
||||
|
||||
// special treatment for octet-stream - try to get content type from file extension
|
||||
if (contentType.type.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
||||
Log.w(TAG, "Special case \"application/octet-stream\"")
|
||||
val headerFieldContentDisposition: String? =
|
||||
connection.getHeaderField("Content-Disposition")
|
||||
if (headerFieldContentDisposition != null) {
|
||||
val fileName: String = headerFieldContentDisposition.split("=")[1].replace(
|
||||
"\"",
|
||||
""
|
||||
) //getting value after '=' & stripping any "s
|
||||
contentType.type = FileHelper.getContentTypeFromExtension(fileName)
|
||||
} else {
|
||||
Log.i(TAG, "Unable to get file name from \"Content-Disposition\" header field.")
|
||||
}
|
||||
}
|
||||
|
||||
connection.disconnect()
|
||||
}
|
||||
Log.i(TAG, "content type: ${contentType.type} | character set: ${contentType.charset}")
|
||||
return contentType
|
||||
}
|
||||
|
||||
|
||||
/* Download playlist - up to 100 lines, with max. 200 characters */
|
||||
fun downloadPlaylist(playlistUrlString: String): List<String> {
|
||||
val lines = mutableListOf<String>()
|
||||
val connection = URL(playlistUrlString).openConnection()
|
||||
val reader = connection.getInputStream().bufferedReader()
|
||||
reader.useLines { sequence ->
|
||||
sequence.take(100).forEach { line ->
|
||||
val trimmedLine = line.take(2000)
|
||||
lines.add(trimmedLine)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Detects content type (mime type) from given URL string - async using coroutine */
|
||||
suspend fun detectContentTypeSuspended(urlString: String): ContentType {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(detectContentType(urlString))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Gets a random radio-browser.info api address - async using coroutine */
|
||||
suspend fun getRadioBrowserServerSuspended(): String {
|
||||
return suspendCoroutine { cont ->
|
||||
val serverAddress: String = try {
|
||||
// get all available radio browser servers
|
||||
val serverAddressList: Array<InetAddress> =
|
||||
InetAddress.getAllByName(Keys.RADIO_BROWSER_API_BASE)
|
||||
// select a random address
|
||||
serverAddressList[Random().nextInt(serverAddressList.size)].canonicalHostName
|
||||
} catch (e: UnknownHostException) {
|
||||
Keys.RADIO_BROWSER_API_DEFAULT
|
||||
}
|
||||
PreferencesHelper.saveRadioBrowserApiAddress(serverAddress)
|
||||
cont.resume(serverAddress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates a http connection from given url string */
|
||||
private fun createConnection(urlString: String, redirectCount: Int = 0): HttpURLConnection? {
|
||||
var connection: HttpURLConnection? = null
|
||||
|
||||
try {
|
||||
// try to open connection and get status
|
||||
Log.i(TAG, "Opening http connection.")
|
||||
connection = URL(urlString).openConnection() as HttpURLConnection
|
||||
val status = connection.responseCode
|
||||
|
||||
// CHECK for non-HTTP_OK status
|
||||
if (status != HttpURLConnection.HTTP_OK) {
|
||||
// CHECK for redirect status
|
||||
if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) {
|
||||
val redirectUrl: String = connection.getHeaderField("Location")
|
||||
connection.disconnect()
|
||||
if (redirectCount < 5) {
|
||||
Log.i(TAG, "Following redirect to $redirectUrl")
|
||||
connection = createConnection(redirectUrl, redirectCount + 1)
|
||||
} else {
|
||||
connection = null
|
||||
Log.e(TAG, "Too many redirects.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open http connection.")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* PreferencesHelper.kt
|
||||
* Implements the PreferencesHelper object
|
||||
* A PreferencesHelper provides helper methods for the saving and loading values from shared preferences
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.gson.Gson
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.ui.PlayerState
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* PreferencesHelper object
|
||||
*/
|
||||
object PreferencesHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = PreferencesHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* The sharedPreferences object to be initialized */
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
/* Initialize a single sharedPreferences object when the app is launched */
|
||||
fun Context.initPreferences() {
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
}
|
||||
|
||||
|
||||
/* Loads address of radio-browser.info API from shared preferences */
|
||||
fun loadRadioBrowserApiAddress(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_RADIO_BROWSER_API,
|
||||
Keys.RADIO_BROWSER_API_DEFAULT
|
||||
) ?: Keys.RADIO_BROWSER_API_DEFAULT
|
||||
}
|
||||
|
||||
|
||||
/* Saves address of radio-browser.info API to shared preferences */
|
||||
fun saveRadioBrowserApiAddress(radioBrowserApi: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_RADIO_BROWSER_API, radioBrowserApi)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of playback for player to shared preferences */
|
||||
fun saveIsPlaying(isPlaying: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Load uuid of the station in the station list which is currently expanded */
|
||||
fun loadStationListStreamUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_STATION_LIST_EXPANDED_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
|
||||
/* Save uuid of the station in the station list which is currently expanded */
|
||||
fun saveStationListStreamUuid(stationUuid: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_STATION_LIST_EXPANDED_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves last update to shared preferences */
|
||||
fun saveLastUpdateCollection(lastUpdate: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_LAST_UPDATE_COLLECTION, DateTimeHelper.convertToRfc2822(lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads size of collection from shared preferences */
|
||||
fun loadCollectionSize(): Int {
|
||||
return sharedPreferences.getInt(Keys.PREF_COLLECTION_SIZE, -1)
|
||||
}
|
||||
|
||||
|
||||
/* Saves site of collection to shared preferences */
|
||||
fun saveCollectionSize(size: Int) {
|
||||
sharedPreferences.edit {
|
||||
putInt(Keys.PREF_COLLECTION_SIZE, size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of sleep timer to shared preferences */
|
||||
fun saveSleepTimerRunning(isRunning: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, isRunning)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads date of last save operation from shared preferences */
|
||||
fun loadCollectionModificationDate(): Date {
|
||||
val modificationDateString: String =
|
||||
sharedPreferences.getString(Keys.PREF_COLLECTION_MODIFICATION_DATE, "") ?: String()
|
||||
return DateTimeHelper.convertFromRfc2822(modificationDateString)
|
||||
}
|
||||
|
||||
|
||||
/* Saves date of last save operation to shared preferences */
|
||||
fun saveCollectionModificationDate(lastSave: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(
|
||||
Keys.PREF_COLLECTION_MODIFICATION_DATE,
|
||||
DateTimeHelper.convertToRfc2822(lastSave)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads active downloads from shared preferences */
|
||||
fun loadActiveDownloads(): String {
|
||||
val activeDownloadsString: String =
|
||||
sharedPreferences.getString(Keys.PREF_ACTIVE_DOWNLOADS, Keys.ACTIVE_DOWNLOADS_EMPTY)
|
||||
?: Keys.ACTIVE_DOWNLOADS_EMPTY
|
||||
Log.v(TAG, "IDs of active downloads: $activeDownloadsString")
|
||||
return activeDownloadsString
|
||||
}
|
||||
|
||||
|
||||
/* Saves active downloads to shared preferences */
|
||||
fun saveActiveDownloads(activeDownloadsString: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_ACTIVE_DOWNLOADS, activeDownloadsString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads state of player user interface from shared preferences */
|
||||
fun loadPlayerState(): PlayerState {
|
||||
return PlayerState().apply {
|
||||
stationUuid = sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String())
|
||||
?: String()
|
||||
isPlaying = sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, false)
|
||||
sleepTimerRunning =
|
||||
sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves Uuid if currently playing station to shared preferences */
|
||||
fun saveCurrentStationId(stationUuid: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_STATE_STATION_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads uuid of last played station from shared preferences */
|
||||
fun loadLastPlayedStationUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
|
||||
/* Saves history of metadata in shared preferences */
|
||||
fun saveMetadataHistory(metadataHistory: MutableList<String>) {
|
||||
val gson = Gson()
|
||||
val json = gson.toJson(metadataHistory)
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_METADATA_HISTORY, json)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads history of metadata from shared preferences */
|
||||
fun loadMetadataHistory(): MutableList<String> {
|
||||
var metadataHistory: MutableList<String> = mutableListOf()
|
||||
val json: String =
|
||||
sharedPreferences.getString(Keys.PREF_PLAYER_METADATA_HISTORY, String()) ?: String()
|
||||
if (json.isNotEmpty()) {
|
||||
val gson = Gson()
|
||||
metadataHistory = gson.fromJson(json, metadataHistory::class.java)
|
||||
}
|
||||
return metadataHistory
|
||||
}
|
||||
|
||||
|
||||
/* Start watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */
|
||||
fun registerPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
|
||||
/* Stop watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */
|
||||
fun unregisterPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
|
||||
/* Checks if housekeeping work needs to be done - used usually in DownloadWorker "REQUEST_UPDATE_COLLECTION" */
|
||||
fun isHouseKeepingNecessary(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, true)
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of housekeeping */
|
||||
fun saveHouseKeepingNecessaryState(state: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Load currently selected app theme */
|
||||
fun loadThemeSelection(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_THEME_SELECTION,
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
) ?: Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Edit Stations */
|
||||
fun loadEditStationsEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STATIONS, true)
|
||||
}
|
||||
|
||||
|
||||
/* Saves value of the option: Edit Stations (only needed for migration) */
|
||||
fun saveEditStationsEnabled(enabled: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_EDIT_STATIONS, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Edit Station Streams */
|
||||
fun loadEditStreamUrisEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STREAMS_URIS, true)
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Buffer Size */
|
||||
fun loadLargeBufferSize(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)
|
||||
}
|
||||
|
||||
|
||||
/* Loads a multiplier value for constructing the load control */
|
||||
fun loadBufferSizeMultiplier(): Int {
|
||||
return if (sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)) {
|
||||
Keys.LARGE_BUFFER_SIZE_MULTIPLIER
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Return whether to download over mobile */
|
||||
fun downloadOverMobile(): Boolean {
|
||||
return sharedPreferences.getBoolean(
|
||||
Keys.PREF_DOWNLOAD_OVER_MOBILE,
|
||||
Keys.DEFAULT_DOWNLOAD_OVER_MOBILE
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* ShortcutHelper.kt
|
||||
* Implements the ShortcutHelper object
|
||||
* A ShortcutHelper creates and handles station shortcuts on the Home screen
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.MainActivity
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
|
||||
|
||||
/*
|
||||
* ShortcutHelper object
|
||||
*/
|
||||
object ShortcutHelper {
|
||||
|
||||
/* Places shortcut on Home screen */
|
||||
fun placeShortcut(context: Context, station: Station) {
|
||||
// credit: https://medium.com/@BladeCoder/using-support-library-26-0-0-you-can-do-bb75911e01e8
|
||||
if (ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||
val shortcut: ShortcutInfoCompat = ShortcutInfoCompat.Builder(context, station.name)
|
||||
.setShortLabel(station.name)
|
||||
.setLongLabel(station.name)
|
||||
.setIcon(createShortcutIcon(context, station.image, station.imageColor))
|
||||
.setIntent(createShortcutIntent(context, station.uuid))
|
||||
.build()
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
Toast.makeText(context, R.string.toastmessage_shortcut_created, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(context, R.string.toastmessage_shortcut_not_created, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates Intent for a station shortcut */
|
||||
private fun createShortcutIntent(context: Context, stationUuid: String): Intent {
|
||||
val shortcutIntent = Intent(context, MainActivity::class.java)
|
||||
shortcutIntent.action = Keys.ACTION_START
|
||||
shortcutIntent.putExtra(Keys.EXTRA_STATION_UUID, stationUuid)
|
||||
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
return shortcutIntent
|
||||
}
|
||||
|
||||
|
||||
/* Create shortcut icon */
|
||||
private fun createShortcutIcon(
|
||||
context: Context,
|
||||
stationImage: String,
|
||||
stationImageColor: Int
|
||||
): IconCompat {
|
||||
val stationImageBitmap: Bitmap =
|
||||
ImageHelper.getScaledStationImage(context, stationImage.toUri(), 192)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
IconCompat.createWithAdaptiveBitmap(
|
||||
ImageHelper.createSquareImage(
|
||||
context,
|
||||
stationImageBitmap,
|
||||
stationImageColor,
|
||||
192,
|
||||
true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
IconCompat.createWithAdaptiveBitmap(
|
||||
ImageHelper.createSquareImage(
|
||||
context,
|
||||
stationImageBitmap,
|
||||
stationImageColor,
|
||||
192,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
282
app/src/main/java/com/michatec/radio/helpers/UiHelper.kt
Normal file
282
app/src/main/java/com/michatec/radio/helpers/UiHelper.kt
Normal file
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* UiHelper.kt
|
||||
* Implements the UiHelper object
|
||||
* A UiHelper provides helper methods for User Interface related tasks
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* UiHelper object
|
||||
*/
|
||||
object UiHelper {
|
||||
|
||||
/* Sets layout margins for given view in DP */
|
||||
fun setViewMargins(
|
||||
context: Context,
|
||||
view: View,
|
||||
left: Int = 0,
|
||||
right: Int = 0,
|
||||
top: Int = 0,
|
||||
bottom: Int = 0
|
||||
) {
|
||||
val l: Int = (left * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val r: Int = (right * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val t: Int = (top * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val b: Int = (bottom * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
if (view.layoutParams is ViewGroup.MarginLayoutParams) {
|
||||
val p = view.layoutParams as ViewGroup.MarginLayoutParams
|
||||
p.setMargins(l, t, r, b)
|
||||
view.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide keyboard */
|
||||
fun hideSoftKeyboard(context: Context, view: View) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: Callback that detects a swipe to left
|
||||
* Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt
|
||||
*/
|
||||
abstract class SwipeToDeleteCallback(context: Context) :
|
||||
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
|
||||
|
||||
private val deleteIcon = ContextCompat.getDrawable(context, R.drawable.ic_remove_circle_24dp)
|
||||
private val intrinsicWidth: Int = deleteIcon?.intrinsicWidth ?: 0
|
||||
private val intrinsicHeight: Int = deleteIcon?.intrinsicHeight ?: 0
|
||||
private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_delete_background)
|
||||
private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
|
||||
private val cornerRadius: Float = dpToPx(context)
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
// disable swipe for the add new card
|
||||
if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) {
|
||||
return 0
|
||||
}
|
||||
return super.getMovementFlags(recyclerView, viewHolder)
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
// do nothing
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float,
|
||||
dY: Float,
|
||||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
val itemView = viewHolder.itemView
|
||||
val itemHeight = itemView.bottom - itemView.top
|
||||
val isCanceled = dX == 0f && !isCurrentlyActive
|
||||
|
||||
if (isCanceled) {
|
||||
clearCanvas(
|
||||
c,
|
||||
itemView.right + dX,
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
super.onChildDraw(
|
||||
c,
|
||||
recyclerView,
|
||||
viewHolder,
|
||||
dX,
|
||||
dY,
|
||||
actionState,
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// draw delete and rounded background
|
||||
val roundedBackground = RectF(
|
||||
itemView.left.toFloat(),
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
|
||||
val paint = Paint()
|
||||
paint.color = backgroundColor
|
||||
c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint)
|
||||
|
||||
// calculate position of delete icon
|
||||
val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconLeft = itemView.right - deleteIconMargin - intrinsicWidth
|
||||
val deleteIconRight = itemView.right - deleteIconMargin
|
||||
val deleteIconBottom = deleteIconTop + intrinsicHeight
|
||||
|
||||
// draw delete icon
|
||||
deleteIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
|
||||
deleteIcon?.draw(c)
|
||||
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
}
|
||||
|
||||
private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
|
||||
c?.drawRect(left, top, right, bottom, clearPaint)
|
||||
}
|
||||
|
||||
// conversion from dp to px
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: Callback that detects a swipe to left
|
||||
* Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt
|
||||
*/
|
||||
abstract class SwipeToMarkStarredCallback(context: Context) :
|
||||
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
|
||||
|
||||
private val starIcon = ContextCompat.getDrawable(context, R.drawable.ic_favorite_24dp)
|
||||
private val intrinsicWidth: Int = starIcon?.intrinsicWidth ?: 0
|
||||
private val intrinsicHeight: Int = starIcon?.intrinsicHeight ?: 0
|
||||
private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_mark_starred_background)
|
||||
private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
|
||||
private val cornerRadius: Float = dpToPx(context)
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
// disable swipe for the add new card
|
||||
if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) {
|
||||
return 0
|
||||
}
|
||||
return super.getMovementFlags(recyclerView, viewHolder)
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
// do nothing
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float,
|
||||
dY: Float,
|
||||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
val itemView = viewHolder.itemView
|
||||
val itemHeight = itemView.bottom - itemView.top
|
||||
val isCanceled = dX == 0f && !isCurrentlyActive
|
||||
|
||||
if (isCanceled) {
|
||||
clearCanvas(
|
||||
c,
|
||||
itemView.right + dX,
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
super.onChildDraw(
|
||||
c,
|
||||
recyclerView,
|
||||
viewHolder,
|
||||
dX,
|
||||
dY,
|
||||
actionState,
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// draw favorite color and rounded background
|
||||
val roundedBackground = RectF(
|
||||
itemView.left.toFloat(),
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
|
||||
val paint = Paint()
|
||||
paint.color = backgroundColor
|
||||
c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint)
|
||||
|
||||
// calculate position of delete icon
|
||||
val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconLeft = itemView.left + deleteIconMargin
|
||||
val deleteIconRight = itemView.left + deleteIconMargin + intrinsicWidth
|
||||
val deleteIconBottom = deleteIconTop + intrinsicHeight
|
||||
|
||||
// draw delete icon
|
||||
starIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
|
||||
starIcon?.draw(c)
|
||||
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
}
|
||||
|
||||
private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
|
||||
c?.drawRect(left, top, right, bottom, clearPaint)
|
||||
}
|
||||
|
||||
// conversion from dp to px
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
}
|
||||
142
app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt
Normal file
142
app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* UpdateHelper.kt
|
||||
* Implements the UpdateHelper class
|
||||
* A UpdateHelper provides methods to update a single station or the whole collection of stations
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.RadioBrowserResult
|
||||
import com.michatec.radio.search.RadioBrowserSearch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
|
||||
|
||||
/*
|
||||
* UpdateHelper class
|
||||
*/
|
||||
class UpdateHelper(
|
||||
private val context: Context,
|
||||
private val updateHelperListener: UpdateHelperListener,
|
||||
private var collection: Collection
|
||||
) : RadioBrowserSearch.RadioBrowserSearchListener {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = UpdateHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private var radioBrowserSearchCounter: Int = 0
|
||||
private var remoteStationLocationsList: MutableList<String> = mutableListOf()
|
||||
|
||||
|
||||
/* Listener Interface */
|
||||
interface UpdateHelperListener {
|
||||
fun onStationUpdated(
|
||||
collection: Collection,
|
||||
positionPriorUpdate: Int,
|
||||
positionAfterUpdate: Int
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */
|
||||
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
|
||||
if (results.isNotEmpty()) {
|
||||
CoroutineScope(IO).launch {
|
||||
// get station from results
|
||||
val station: Station = results[0].toStation()
|
||||
// detect content type
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// update content type
|
||||
station.streamContent = contentType.type
|
||||
// get position
|
||||
val positionPriorUpdate =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
// update (and sort) collection
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
// get new position
|
||||
val positionAfterUpdate: Int =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
// hand over results
|
||||
withContext(Main) {
|
||||
updateHelperListener.onStationUpdated(
|
||||
collection,
|
||||
positionPriorUpdate,
|
||||
positionAfterUpdate
|
||||
)
|
||||
}
|
||||
// decrease counter
|
||||
radioBrowserSearchCounter--
|
||||
// all downloads from radio browser succeeded
|
||||
if (radioBrowserSearchCounter == 0 && remoteStationLocationsList.isNotEmpty()) {
|
||||
// direct download of playlists
|
||||
DownloadHelper.downloadPlaylists(
|
||||
context,
|
||||
remoteStationLocationsList.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates the whole collection of stations */
|
||||
fun updateCollection() {
|
||||
PreferencesHelper.saveLastUpdateCollection()
|
||||
collection.stations.forEach { station ->
|
||||
when {
|
||||
station.radioBrowserStationUuid.isNotEmpty() -> {
|
||||
// increase counter
|
||||
radioBrowserSearchCounter++
|
||||
// request download from radio browser
|
||||
downloadFromRadioBrowser(station.radioBrowserStationUuid)
|
||||
}
|
||||
station.remoteStationLocation.isNotEmpty() -> {
|
||||
// add playlist link to list for later(!) download in onRadioBrowserSearchResults
|
||||
remoteStationLocationsList.add(station.remoteStationLocation)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unable to update station: ${station.name}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
// special case: collection contained only playlist files
|
||||
if (radioBrowserSearchCounter == 0) {
|
||||
// direct download of playlists
|
||||
DownloadHelper.downloadPlaylists(context, remoteStationLocationsList.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get updated station from radio browser - results are handled by onRadioBrowserSearchResults */
|
||||
private fun downloadFromRadioBrowser(radioBrowserStationUuid: String) {
|
||||
val radioBrowserSearch = RadioBrowserSearch(this)
|
||||
radioBrowserSearch.searchStation(context, radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID)
|
||||
}
|
||||
|
||||
}
|
||||
111
app/src/main/java/com/michatec/radio/search/DirectInputCheck.kt
Normal file
111
app/src/main/java/com/michatec/radio/search/DirectInputCheck.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* DirectInputCheck.kt
|
||||
* Implements the DirectInputCheck class
|
||||
* A DirectInputCheck checks if a station url is valid and returns station via a listener
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-23 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.search
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.URLUtil
|
||||
import android.widget.Toast
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.helpers.CollectionHelper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
|
||||
data class IcecastMetadata(
|
||||
val title: String?
|
||||
)
|
||||
/*
|
||||
* DirectInputCheck class
|
||||
*/
|
||||
class DirectInputCheck(private var directInputCheckListener: DirectInputCheckListener) {
|
||||
|
||||
/* Interface used to send back station list for checked */
|
||||
interface DirectInputCheckListener {
|
||||
fun onDirectInputCheck(stationList: MutableList<Station>) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private var lastCheckedAddress: String = String()
|
||||
|
||||
|
||||
/* Searches station(s) on radio-browser.info */
|
||||
fun checkStationAddress(context: Context, query: String) {
|
||||
// check if valid URL
|
||||
if (URLUtil.isValidUrl(query)) {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
CoroutineScope(IO).launch {
|
||||
stationList.addAll(CollectionHelper.createStationsFromUrl(query, lastCheckedAddress))
|
||||
lastCheckedAddress = query
|
||||
withContext(Main) {
|
||||
if (stationList.isNotEmpty()) {
|
||||
// hand over station is to listener
|
||||
directInputCheckListener.onDirectInputCheck(stationList)
|
||||
} else {
|
||||
// invalid address
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun extractIcecastMetadata(streamUri: String): IcecastMetadata {
|
||||
return withContext(IO) {
|
||||
// make an HTTP request at the stream URL to get Icecast metadata.
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder()
|
||||
.url(streamUri)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val icecastHeaders = response.headers
|
||||
|
||||
// analyze the Icecast metadata and extract information like title, description, bitrate, etc.
|
||||
val title = icecastHeaders["icy-name"]
|
||||
|
||||
IcecastMetadata(title?.takeIf { it.isNotEmpty() } ?: streamUri)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun updateStationWithIcecastMetadata(station: Station, icecastMetadata: IcecastMetadata) {
|
||||
withContext(Dispatchers.Default) {
|
||||
station.name = icecastMetadata.title.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun processIcecastStream(streamUri: String, stationList: MutableList<Station>) {
|
||||
val icecastMetadata = extractIcecastMetadata(streamUri)
|
||||
val station = Station(name = icecastMetadata.title.toString(), streamUris = mutableListOf(streamUri), modificationDate = GregorianCalendar.getInstance().time)
|
||||
updateStationWithIcecastMetadata(station, icecastMetadata)
|
||||
// create station and add to collection
|
||||
if (lastCheckedAddress != streamUri) {
|
||||
stationList.add(station)
|
||||
}
|
||||
lastCheckedAddress = streamUri
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* RadioBrowserResult.kt
|
||||
* Implements the RadioBrowserResult class
|
||||
* A RadioBrowserResult is the search result of a request to api.radio-browser.info
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.search
|
||||
|
||||
import com.google.gson.annotations.Expose
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Station
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* RadioBrowserResult class
|
||||
*/
|
||||
data class RadioBrowserResult(
|
||||
@Expose val changeuuid: String,
|
||||
@Expose val stationuuid: String,
|
||||
@Expose val name: String,
|
||||
@Expose val url: String,
|
||||
@Expose val url_resolved: String,
|
||||
@Expose val homepage: String,
|
||||
@Expose val favicon: String,
|
||||
@Expose val bitrate: Int,
|
||||
@Expose val codec: String
|
||||
) {
|
||||
|
||||
/* Converts RadioBrowserResult to Station */
|
||||
fun toStation(): Station = Station(
|
||||
starred = false,
|
||||
name = name,
|
||||
nameManuallySet = false,
|
||||
streamUris = mutableListOf(url_resolved),
|
||||
stream = 0,
|
||||
streamContent = Keys.MIME_TYPE_UNSUPPORTED,
|
||||
homepage = homepage,
|
||||
image = String(),
|
||||
smallImage = String(),
|
||||
imageColor = -1,
|
||||
imageManuallySet = false,
|
||||
remoteImageLocation = favicon,
|
||||
remoteStationLocation = url,
|
||||
modificationDate = GregorianCalendar.getInstance().time,
|
||||
isPlaying = false,
|
||||
radioBrowserStationUuid = stationuuid,
|
||||
radioBrowserChangeUuid = changeuuid,
|
||||
bitrate = bitrate,
|
||||
codec = codec
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
JSON Struct Station
|
||||
https://de1.api.radio-browser.info/
|
||||
|
||||
changeuuid UUID A globally unique identifier for the change of the station information
|
||||
stationuuid UUID A globally unique identifier for the station
|
||||
name string The name of the station
|
||||
url string, URL (HTTP/HTTPS) The stream URL provided by the user
|
||||
url_resolved string, URL (HTTP/HTTPS) An automatically "resolved" stream URL. Things resolved are playlists (M3U/PLS/ASX...), HTTP redirects (Code 301/302). This link is especially usefull if you use this API from a platform that is not able to do a resolve on its own (e.g. JavaScript in browser) or you just don't want to invest the time in decoding playlists yourself.
|
||||
homepage string, URL (HTTP/HTTPS) URL to the homepage of the stream, so you can direct the user to a page with more information about the stream.
|
||||
favicon string, URL (HTTP/HTTPS) URL to an icon or picture that represents the stream. (PNG, JPG)
|
||||
tags string, multivalue, split by comma Tags of the stream with more information about it
|
||||
country string DEPRECATED: use countrycode instead, full name of the country
|
||||
countrycode 2 letters, uppercase Official countrycodes as in ISO 3166-1 alpha-2
|
||||
state string Full name of the entity where the station is located inside the country
|
||||
language string, multivalue, split by comma Languages that are spoken in this stream.
|
||||
votes number, integer Number of votes for this station. This number is by server and only ever increases. It will never be reset to 0.
|
||||
lastchangetime datetime, YYYY-MM-DD HH:mm:ss Last time when the stream information was changed in the database
|
||||
codec string The codec of this stream recorded at the last check.
|
||||
bitrate number, integer, bps The bitrate of this stream recorded at the last check.
|
||||
hls 0 or 1 Mark if this stream is using HLS distribution or non-HLS.
|
||||
lastcheckok 0 or 1 The current online/offline state of this stream. This is a value calculated from multiple measure points in the internet. The test servers are located in different countries. It is a majority vote.
|
||||
lastchecktime datetime, YYYY-MM-DD HH:mm:ss The last time when any radio-browser server checked the online state of this stream
|
||||
lastcheckoktime datetime, YYYY-MM-DD HH:mm:ss The last time when the stream was checked for the online status with a positive result
|
||||
lastlocalchecktime datetime, YYYY-MM-DD HH:mm:ss The last time when this server checked the online state and the metadata of this stream
|
||||
clicktimestamp datetime, YYYY-MM-DD HH:mm:ss The time of the last click recorded for this stream
|
||||
clickcount number, integer Clicks within the last 24 hours
|
||||
clicktrend number, integer The difference of the clickcounts within the last 2 days. Posivite values mean an increase, negative a decrease of clicks.
|
||||
*/
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* RadioBrowserSearch.kt
|
||||
* Implements the RadioBrowserSearch class
|
||||
* A RadioBrowserSearch performs searches on the radio-browser.info database
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.search
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.android.volley.*
|
||||
import com.android.volley.toolbox.JsonArrayRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import com.google.gson.GsonBuilder
|
||||
import org.json.JSONArray
|
||||
import com.michatec.radio.BuildConfig
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.helpers.NetworkHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
|
||||
|
||||
/*
|
||||
* RadioBrowserSearch class
|
||||
*/
|
||||
class RadioBrowserSearch(private var radioBrowserSearchListener: RadioBrowserSearchListener) {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = RadioBrowserSearch::class.java.simpleName
|
||||
|
||||
|
||||
/* Interface used to send back search results */
|
||||
interface RadioBrowserSearchListener {
|
||||
fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private var radioBrowserApi: String
|
||||
private lateinit var requestQueue: RequestQueue
|
||||
|
||||
|
||||
/* Init constructor */
|
||||
init {
|
||||
// get address of radio-browser.info api and update it in background
|
||||
radioBrowserApi = PreferencesHelper.loadRadioBrowserApiAddress()
|
||||
updateRadioBrowserApi()
|
||||
}
|
||||
|
||||
|
||||
/* Searches station(s) on radio-browser.info */
|
||||
fun searchStation(context: Context, query: String, searchType: Int) {
|
||||
Log.v(TAG, "Search - Querying $radioBrowserApi for: $query")
|
||||
|
||||
// create queue and request
|
||||
requestQueue = Volley.newRequestQueue(context)
|
||||
val requestUrl: String = when (searchType) {
|
||||
// CASE: single station search - by radio browser UUID
|
||||
Keys.SEARCH_TYPE_BY_UUID -> "https://${radioBrowserApi}/json/stations/byuuid/${query}"
|
||||
// CASE: multiple results search by search term
|
||||
else -> "https://${radioBrowserApi}/json/stations/search?name=${query.replace(" ", "+")}"
|
||||
}
|
||||
|
||||
// request data from request URL
|
||||
val stringRequest = object: JsonArrayRequest(Method.GET, requestUrl, null, responseListener, errorListener) {
|
||||
@Throws(AuthFailureError::class)
|
||||
override fun getHeaders(): Map<String, String> {
|
||||
val params = HashMap<String, String>()
|
||||
params["User-Agent"] = "$Keys.APPLICATION_NAME ${BuildConfig.VERSION_NAME}"
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
// override retry policy
|
||||
stringRequest.retryPolicy = object : RetryPolicy {
|
||||
override fun getCurrentTimeout(): Int {
|
||||
return 30000
|
||||
}
|
||||
|
||||
override fun getCurrentRetryCount(): Int {
|
||||
return 30000
|
||||
}
|
||||
|
||||
@Throws(VolleyError::class)
|
||||
override fun retry(error: VolleyError) {
|
||||
Log.w(TAG, "Error: $error")
|
||||
}
|
||||
}
|
||||
|
||||
// add to RequestQueue.
|
||||
requestQueue.add(stringRequest)
|
||||
}
|
||||
|
||||
|
||||
fun stopSearchRequest() {
|
||||
if (this::requestQueue.isInitialized) {
|
||||
requestQueue.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Converts search result JSON string */
|
||||
private fun createRadioBrowserResult(result: String): Array<RadioBrowserResult> {
|
||||
val gsonBuilder = GsonBuilder()
|
||||
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
|
||||
val gson = gsonBuilder.create()
|
||||
return gson.fromJson(result, Array<RadioBrowserResult>::class.java)
|
||||
}
|
||||
|
||||
|
||||
/* Updates the address of the radio-browser.info api */
|
||||
private fun updateRadioBrowserApi() {
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<String> = async { NetworkHelper.getRadioBrowserServerSuspended() }
|
||||
radioBrowserApi = deferred.await()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Listens for (positive) server responses to search requests */
|
||||
private val responseListener: Response.Listener<JSONArray> = Response.Listener<JSONArray> { response ->
|
||||
if (response != null) {
|
||||
radioBrowserSearchListener.onRadioBrowserSearchResults(createRadioBrowserResult(response.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Listens for error response from server */
|
||||
private val errorListener: Response.ErrorListener = Response.ErrorListener { error ->
|
||||
Log.w(TAG, "Error: $error")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* SearchResultAdapter.kt
|
||||
* Implements the SearchResultAdapter class
|
||||
* A SearchResultAdapter is a custom adapter providing search result views for a RecyclerView
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.search
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
|
||||
|
||||
/*
|
||||
* SearchResultAdapter class
|
||||
*/
|
||||
class SearchResultAdapter(
|
||||
private val listener: SearchResultAdapterListener,
|
||||
var searchResults: List<Station>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
/* Main class variables */
|
||||
private var selectedPosition: Int = RecyclerView.NO_POSITION
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var paused: Boolean = false
|
||||
private var isItemSelected: Boolean = false
|
||||
|
||||
/* Listener Interface */
|
||||
interface SearchResultAdapterListener {
|
||||
fun onSearchResultTapped(result: Station)
|
||||
fun activateAddButton()
|
||||
fun deactivateAddButton()
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onCreateViewHolder from RecyclerView.Adapter */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.element_search_result, parent, false)
|
||||
return SearchResultViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides getItemCount from RecyclerView.Adapter */
|
||||
override fun getItemCount(): Int {
|
||||
return searchResults.size
|
||||
}
|
||||
|
||||
|
||||
/* Overrides getItemCount from RecyclerView.Adapter */
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
|
||||
/* Overrides onBindViewHolder from RecyclerView.Adapter */
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
// get reference to ViewHolder
|
||||
val searchResultViewHolder: SearchResultViewHolder = holder as SearchResultViewHolder
|
||||
val searchResult: Station = searchResults[position]
|
||||
|
||||
// update text
|
||||
searchResultViewHolder.nameView.text = searchResult.name
|
||||
searchResultViewHolder.streamView.text = searchResult.getStreamUri()
|
||||
|
||||
if (searchResult.codec.isNotEmpty()) {
|
||||
if (searchResult.bitrate == 0) {
|
||||
// show only the codec when the bitrate is at "0" from radio-browser.info API
|
||||
searchResultViewHolder.bitrateView.text = searchResult.codec
|
||||
} else {
|
||||
// show the bitrate and codec if the result is available in the radio-browser.info API
|
||||
searchResultViewHolder.bitrateView.text = buildString {
|
||||
append(searchResult.codec)
|
||||
append(" | ")
|
||||
append(searchResult.bitrate)
|
||||
append("kbps")}
|
||||
}
|
||||
} else {
|
||||
// do not show for M3U and PLS playlists as they do not include codec or bitrate
|
||||
searchResultViewHolder.bitrateView.visibility = View.GONE
|
||||
}
|
||||
|
||||
// mark selected if necessary
|
||||
val isSelected = selectedPosition == holder.adapterPosition
|
||||
searchResultViewHolder.searchResultLayout.isSelected = isSelected
|
||||
|
||||
// toggle text scrolling (marquee) if necessary
|
||||
searchResultViewHolder.nameView.isSelected = isSelected
|
||||
searchResultViewHolder.streamView.isSelected = isSelected
|
||||
|
||||
// reduce the shadow left and right because of scrolling (Marquee)
|
||||
searchResultViewHolder.nameView.setFadingEdgeLength(10)
|
||||
searchResultViewHolder.streamView.setFadingEdgeLength(10)
|
||||
|
||||
// attach touch listener
|
||||
searchResultViewHolder.searchResultLayout.setOnClickListener {
|
||||
// move marked position
|
||||
val previousSelectedPosition = selectedPosition
|
||||
selectedPosition = holder.adapterPosition
|
||||
notifyItemChanged(previousSelectedPosition)
|
||||
notifyItemChanged(selectedPosition)
|
||||
|
||||
// check if the selected position is the same as before
|
||||
val samePositionSelected = previousSelectedPosition == selectedPosition
|
||||
|
||||
if (samePositionSelected) {
|
||||
// if the same position is selected again, reset the selection
|
||||
resetSelection(false)
|
||||
} else {
|
||||
// get the selected station from searchResults
|
||||
val selectedStation = searchResults[holder.adapterPosition]
|
||||
// perform pre-playback here
|
||||
performPrePlayback(searchResultViewHolder.searchResultLayout.context, selectedStation.getStreamUri())
|
||||
// hand over station
|
||||
listener.onSearchResultTapped(searchResult)
|
||||
}
|
||||
|
||||
// update isItemSelected based on the selection
|
||||
isItemSelected = !samePositionSelected
|
||||
|
||||
// enable/disable the Add button based on isItemSelected
|
||||
if (isItemSelected) {
|
||||
listener.activateAddButton()
|
||||
} else {
|
||||
listener.deactivateAddButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun performPrePlayback(context: Context, streamUri: String) {
|
||||
if (streamUri.contains(".m3u8")) {
|
||||
// release previous player if it exists
|
||||
stopPrePlayback()
|
||||
|
||||
// show toast when no playback is possible
|
||||
Toast.makeText(context, R.string.toastmessage_preview_playback_failed, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
stopRadioPlayback(context)
|
||||
|
||||
// release previous player if it exists
|
||||
stopPrePlayback()
|
||||
|
||||
// create a new instance of ExoPlayer
|
||||
exoPlayer = ExoPlayer.Builder(context).build()
|
||||
|
||||
// create a MediaItem with the streamUri
|
||||
val mediaItem = MediaItem.fromUri(streamUri)
|
||||
|
||||
// set the MediaItem to the ExoPlayer
|
||||
exoPlayer?.setMediaItem(mediaItem)
|
||||
|
||||
// prepare and start the ExoPlayer
|
||||
exoPlayer?.prepare()
|
||||
exoPlayer?.play()
|
||||
|
||||
// show toast when playback is possible
|
||||
Toast.makeText(context, R.string.toastmessage_preview_playback_started, Toast.LENGTH_SHORT).show()
|
||||
|
||||
// listen for app pause events
|
||||
val lifecycle = (context as AppCompatActivity).lifecycle
|
||||
val lifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
if (!paused) {
|
||||
paused = true
|
||||
stopPrePlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun stopPrePlayback() {
|
||||
// stop the ExoPlayer and release resources
|
||||
exoPlayer?.stop()
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
|
||||
|
||||
private fun stopRadioPlayback(context: Context) {
|
||||
// stop radio playback when one is active
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.build()
|
||||
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
// For older versions where AudioFocusRequest is not available
|
||||
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Resets the selected position */
|
||||
fun resetSelection(clearAdapter: Boolean) {
|
||||
val currentlySelected: Int = selectedPosition
|
||||
selectedPosition = RecyclerView.NO_POSITION
|
||||
if (clearAdapter) {
|
||||
val previousItemCount = itemCount
|
||||
searchResults = emptyList()
|
||||
notifyItemRangeRemoved(0, previousItemCount)
|
||||
} else {
|
||||
notifyItemChanged(currentlySelected)
|
||||
stopPrePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: ViewHolder for a radio station search result
|
||||
*/
|
||||
private inner class SearchResultViewHolder(var searchResultLayout: View) :
|
||||
RecyclerView.ViewHolder(searchResultLayout) {
|
||||
val nameView: MaterialTextView = searchResultLayout.findViewById(R.id.station_name)
|
||||
val streamView: MaterialTextView = searchResultLayout.findViewById(R.id.station_url)
|
||||
val bitrateView: MaterialTextView = searchResultLayout.findViewById(R.id.station_bitrate)
|
||||
}
|
||||
|
||||
}
|
||||
454
app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
Normal file
454
app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
Normal file
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* LayoutHolder.kt
|
||||
* Implements the LayoutHolder class
|
||||
* A LayoutHolder hold references to the main views
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.helpers.DateTimeHelper
|
||||
import com.michatec.radio.helpers.ImageHelper
|
||||
import com.michatec.radio.helpers.PreferencesHelper
|
||||
import com.michatec.radio.helpers.UiHelper
|
||||
|
||||
|
||||
/*
|
||||
* LayoutHolder class
|
||||
*/
|
||||
data class LayoutHolder(var rootView: View) {
|
||||
|
||||
/* Main class variables */
|
||||
var recyclerView: RecyclerView = rootView.findViewById(R.id.station_list)
|
||||
val layoutManager: LinearLayoutManager
|
||||
private var bottomSheet: ConstraintLayout = rootView.findViewById(R.id.bottom_sheet)
|
||||
|
||||
//private var sheetMetadataViews: Group
|
||||
private var sleepTimerRunningViews: Group = rootView.findViewById(R.id.sleep_timer_running_views)
|
||||
private var downloadProgressIndicator: ProgressBar = rootView.findViewById(R.id.download_progress_indicator)
|
||||
private var stationImageView: ImageView = rootView.findViewById(R.id.station_icon)
|
||||
private var stationNameView: TextView = rootView.findViewById(R.id.player_station_name)
|
||||
private var metadataView: TextView = rootView.findViewById(R.id.player_station_metadata)
|
||||
var playButtonView: ImageButton = rootView.findViewById(R.id.player_play_button)
|
||||
private var bufferingIndicator: ProgressBar = rootView.findViewById(R.id.player_buffering_indicator)
|
||||
private var sheetStreamingLinkHeadline: TextView = rootView.findViewById(R.id.sheet_streaming_link_headline)
|
||||
private var sheetStreamingLinkView: TextView = rootView.findViewById(R.id.sheet_streaming_link)
|
||||
private var sheetMetadataHistoryHeadline: TextView = rootView.findViewById(R.id.sheet_metadata_headline)
|
||||
private var sheetMetadataHistoryView: TextView = rootView.findViewById(R.id.sheet_metadata_history)
|
||||
private var sheetNextMetadataView: ImageButton = rootView.findViewById(R.id.sheet_next_metadata_button)
|
||||
private var sheetPreviousMetadataView: ImageButton = rootView.findViewById(R.id.sheet_previous_metadata_button)
|
||||
private var sheetCopyMetadataButtonView: ImageButton = rootView.findViewById(R.id.copy_station_metadata_button)
|
||||
private var sheetShareLinkButtonView: ImageView = rootView.findViewById(R.id.sheet_share_link_button)
|
||||
private var sheetBitrateView: TextView = rootView.findViewById(R.id.sheet_bitrate_view)
|
||||
var sheetSleepTimerStartButtonView: ImageButton = rootView.findViewById(R.id.sleep_timer_start_button)
|
||||
var sheetSleepTimerCancelButtonView: ImageButton = rootView.findViewById(R.id.sleep_timer_cancel_button)
|
||||
private var sheetSleepTimerRemainingTimeView: TextView = rootView.findViewById(R.id.sleep_timer_remaining_time)
|
||||
private var onboardingLayout: ConstraintLayout = rootView.findViewById(R.id.onboarding_layout)
|
||||
private var bottomSheetBehavior: BottomSheetBehavior<ConstraintLayout> = BottomSheetBehavior.from(bottomSheet)
|
||||
private var metadataHistory: MutableList<String>
|
||||
private var metadataHistoryPosition: Int
|
||||
private var isBuffering: Boolean
|
||||
|
||||
|
||||
/* Init block */
|
||||
init {
|
||||
// find views
|
||||
//sheetMetadataViews = rootView.findViewById(R.id.sheet_metadata_views)
|
||||
metadataHistory = PreferencesHelper.loadMetadataHistory()
|
||||
metadataHistoryPosition = metadataHistory.size - 1
|
||||
isBuffering = false
|
||||
|
||||
// set up RecyclerView
|
||||
layoutManager = CustomLayoutManager(rootView.context)
|
||||
recyclerView.layoutManager = layoutManager
|
||||
recyclerView.itemAnimator = DefaultItemAnimator()
|
||||
|
||||
// set up metadata history next and previous buttons
|
||||
sheetPreviousMetadataView.setOnClickListener {
|
||||
if (metadataHistory.isNotEmpty()) {
|
||||
if (metadataHistoryPosition > 0) {
|
||||
metadataHistoryPosition -= 1
|
||||
} else {
|
||||
metadataHistoryPosition = metadataHistory.size - 1
|
||||
}
|
||||
sheetMetadataHistoryView.text = metadataHistory[metadataHistoryPosition]
|
||||
}
|
||||
}
|
||||
sheetNextMetadataView.setOnClickListener {
|
||||
if (metadataHistory.isNotEmpty()) {
|
||||
if (metadataHistoryPosition < metadataHistory.size - 1) {
|
||||
metadataHistoryPosition += 1
|
||||
} else {
|
||||
metadataHistoryPosition = 0
|
||||
}
|
||||
sheetMetadataHistoryView.text = metadataHistory[metadataHistoryPosition]
|
||||
}
|
||||
}
|
||||
sheetMetadataHistoryView.setOnLongClickListener {
|
||||
copyMetadataHistoryToClipboard()
|
||||
return@setOnLongClickListener true
|
||||
}
|
||||
sheetMetadataHistoryHeadline.setOnLongClickListener {
|
||||
copyMetadataHistoryToClipboard()
|
||||
return@setOnLongClickListener true
|
||||
}
|
||||
|
||||
// set layout for player
|
||||
setupBottomSheet()
|
||||
}
|
||||
|
||||
|
||||
/* Updates the player views */
|
||||
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
|
||||
|
||||
// set default metadata views, when playback has stopped
|
||||
if (!isPlaying) {
|
||||
metadataView.text = station.name
|
||||
sheetMetadataHistoryView.text = station.name
|
||||
// sheetMetadataHistoryView.isSelected = true
|
||||
}
|
||||
|
||||
// update name
|
||||
stationNameView.text = station.name
|
||||
|
||||
// toggle text scrolling (marquee) if necessary
|
||||
stationNameView.isSelected = isPlaying
|
||||
|
||||
// reduce the shadow left and right because of scrolling (Marquee)
|
||||
stationNameView.setFadingEdgeLength(8)
|
||||
|
||||
// update cover
|
||||
if (station.imageColor != -1) {
|
||||
stationImageView.setBackgroundColor(station.imageColor)
|
||||
}
|
||||
stationImageView.setImageBitmap(ImageHelper.getStationImage(context, station.smallImage))
|
||||
stationImageView.contentDescription = "${context.getString(R.string.descr_player_station_image)}: ${station.name}"
|
||||
|
||||
// update streaming link
|
||||
sheetStreamingLinkView.text = station.getStreamUri()
|
||||
|
||||
val bitrateText: CharSequence = if (station.codec.isNotEmpty()) {
|
||||
if (station.bitrate == 0) {
|
||||
// show only the codec when the bitrate is at "0" from radio-browser.info API
|
||||
station.codec
|
||||
} else {
|
||||
// show the bitrate and codec if the result is available in the radio-browser.info API
|
||||
buildString {
|
||||
append(station.codec)
|
||||
append(" | ")
|
||||
append(station.bitrate)
|
||||
append("kbps")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// do not show for M3U and PLS playlists as they do not include codec or bitrate
|
||||
""
|
||||
}
|
||||
|
||||
// update bitrate
|
||||
sheetBitrateView.text = bitrateText
|
||||
|
||||
// update click listeners
|
||||
sheetStreamingLinkHeadline.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetStreamingLinkView.text
|
||||
)
|
||||
}
|
||||
sheetStreamingLinkView.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetStreamingLinkView.text
|
||||
)
|
||||
}
|
||||
sheetMetadataHistoryHeadline.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetMetadataHistoryView.text
|
||||
)
|
||||
}
|
||||
sheetMetadataHistoryView.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetMetadataHistoryView.text
|
||||
)
|
||||
}
|
||||
sheetCopyMetadataButtonView.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetMetadataHistoryView.text
|
||||
)
|
||||
}
|
||||
sheetBitrateView.setOnClickListener {
|
||||
copyToClipboard(
|
||||
context,
|
||||
sheetBitrateView.text
|
||||
)
|
||||
}
|
||||
sheetShareLinkButtonView.setOnClickListener {
|
||||
val share = Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TITLE, stationNameView.text)
|
||||
putExtra(Intent.EXTRA_TEXT, sheetStreamingLinkView.text)
|
||||
type = "text/plain"
|
||||
}, null)
|
||||
context.startActivity(share)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Copies given string to clipboard */
|
||||
private fun copyToClipboard(context: Context, clipString: CharSequence) {
|
||||
val clip: ClipData = ClipData.newPlainText("simple text", clipString)
|
||||
val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// since API 33 (TIRAMISU) the OS displays its own notification when content is copied to the clipboard
|
||||
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Copies collected metadata to clipboard */
|
||||
private fun copyMetadataHistoryToClipboard() {
|
||||
val metadataHistory: MutableList<String> = PreferencesHelper.loadMetadataHistory()
|
||||
val stringBuilder: StringBuilder = StringBuilder()
|
||||
metadataHistory.forEach { stringBuilder.append("${it.trim()}\n") }
|
||||
copyToClipboard(rootView.context, stringBuilder.toString())
|
||||
}
|
||||
|
||||
|
||||
/* Updates the metadata views */
|
||||
fun updateMetadata(metadataHistoryList: MutableList<String>?) {
|
||||
if (!metadataHistoryList.isNullOrEmpty()) {
|
||||
metadataHistory = metadataHistoryList
|
||||
if (metadataHistory.last() != metadataView.text) {
|
||||
metadataHistoryPosition = metadataHistory.size - 1
|
||||
val metadataString = metadataHistory[metadataHistoryPosition]
|
||||
metadataView.text = metadataString
|
||||
sheetMetadataHistoryView.text = metadataString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates sleep timer views */
|
||||
fun updateSleepTimer(context: Context, timeRemaining: Long = 0L) {
|
||||
when (timeRemaining) {
|
||||
0L -> {
|
||||
sleepTimerRunningViews.isGone = true
|
||||
}
|
||||
else -> {
|
||||
sleepTimerRunningViews.isVisible = true
|
||||
val sleepTimerTimeRemaining = DateTimeHelper.convertToHoursMinutesSeconds(timeRemaining)
|
||||
sheetSleepTimerRemainingTimeView.text = sleepTimerTimeRemaining
|
||||
sheetSleepTimerRemainingTimeView.contentDescription = "${context.getString(R.string.descr_expanded_player_sleep_timer_remaining_time)}: $sleepTimerTimeRemaining"
|
||||
stationNameView.isSelected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Toggles play/pause button */
|
||||
fun togglePlayButton(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
playButtonView.setImageResource(R.drawable.ic_audio_waves_animated)
|
||||
val animatedVectorDrawable = playButtonView.drawable as? AnimatedVectorDrawable
|
||||
animatedVectorDrawable?.start()
|
||||
sheetSleepTimerStartButtonView.isVisible = true
|
||||
// bufferingIndicator.isVisible = false
|
||||
} else {
|
||||
playButtonView.setImageResource(R.drawable.ic_player_play_symbol_42dp)
|
||||
sheetSleepTimerStartButtonView.isVisible = false
|
||||
// bufferingIndicator.isVisible = isBuffering
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Toggles buffering indicator */
|
||||
fun showBufferingIndicator(buffering: Boolean) {
|
||||
bufferingIndicator.isVisible = buffering
|
||||
isBuffering = buffering
|
||||
}
|
||||
|
||||
|
||||
/* Toggles visibility of player depending on playback state - hiding it when playback is stopped (not paused or playing) */
|
||||
// fun togglePlayerVisibility(context: Context, playbackState: Int): Boolean {
|
||||
// when (playbackState) {
|
||||
// PlaybackStateCompat.STATE_STOPPED -> return hidePlayer(context)
|
||||
// PlaybackStateCompat.STATE_NONE -> return hidePlayer(context)
|
||||
// PlaybackStateCompat.STATE_ERROR -> return hidePlayer(context)
|
||||
// else -> return showPlayer(context)
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
/* Toggles visibility of the download progress indicator */
|
||||
fun toggleDownloadProgressIndicator() {
|
||||
when (PreferencesHelper.loadActiveDownloads()) {
|
||||
Keys.ACTIVE_DOWNLOADS_EMPTY -> downloadProgressIndicator.isGone = true
|
||||
else -> downloadProgressIndicator.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Toggles visibility of the onboarding screen */
|
||||
fun toggleOnboarding(context: Context, collectionSize: Int): Boolean {
|
||||
return if (collectionSize == 0 && PreferencesHelper.loadCollectionSize() <= 0) {
|
||||
onboardingLayout.isVisible = true
|
||||
hidePlayer(context)
|
||||
true
|
||||
} else {
|
||||
onboardingLayout.isGone = true
|
||||
showPlayer(context)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Initiates the rotation animation of the play button */
|
||||
fun animatePlaybackButtonStateTransition(context: Context, isPlaying: Boolean) {
|
||||
when (isPlaying) {
|
||||
true -> {
|
||||
val rotateClockwise = AnimationUtils.loadAnimation(context, R.anim.rotate_clockwise_slow)
|
||||
rotateClockwise.setAnimationListener(createAnimationListener(true))
|
||||
playButtonView.startAnimation(rotateClockwise)
|
||||
}
|
||||
false -> {
|
||||
val rotateCounterClockwise = AnimationUtils.loadAnimation(context, R.anim.rotate_counterclockwise_fast)
|
||||
rotateCounterClockwise.setAnimationListener(createAnimationListener(false))
|
||||
playButtonView.startAnimation(rotateCounterClockwise)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Shows player */
|
||||
fun showPlayer(context: Context): Boolean {
|
||||
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, Keys.BOTTOM_SHEET_PEEK_HEIGHT)
|
||||
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_HIDDEN && onboardingLayout.visibility == View.GONE) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Hides player */
|
||||
private fun hidePlayer(context: Context): Boolean {
|
||||
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, 0)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Minimizes player sheet if expanded */
|
||||
fun minimizePlayerIfExpanded(): Boolean {
|
||||
return if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates AnimationListener for play button */
|
||||
private fun createAnimationListener(isPlaying: Boolean): Animation.AnimationListener {
|
||||
return object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation) {}
|
||||
override fun onAnimationEnd(animation: Animation) {
|
||||
// set up button symbol and playback indicator afterwards
|
||||
togglePlayButton(isPlaying)
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animation) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sets up the player (BottomSheet) */
|
||||
private fun setupBottomSheet() {
|
||||
// show / hide the small player
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.addBottomSheetCallback(object :
|
||||
BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(view: View, slideOffset: Float) {
|
||||
}
|
||||
|
||||
override fun onStateChanged(view: View, state: Int) {
|
||||
when (state) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> Unit // do nothing
|
||||
BottomSheetBehavior.STATE_DRAGGING -> Unit // do nothing
|
||||
BottomSheetBehavior.STATE_EXPANDED -> Unit // do nothing
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> Unit // do nothing
|
||||
BottomSheetBehavior.STATE_SETTLING -> Unit // do nothing
|
||||
BottomSheetBehavior.STATE_HIDDEN -> showPlayer(rootView.context)
|
||||
}
|
||||
}
|
||||
})
|
||||
// toggle collapsed state on tap
|
||||
bottomSheet.setOnClickListener { toggleBottomSheetState() }
|
||||
stationImageView.setOnClickListener { toggleBottomSheetState() }
|
||||
stationNameView.setOnClickListener { toggleBottomSheetState() }
|
||||
metadataView.setOnClickListener { toggleBottomSheetState() }
|
||||
}
|
||||
|
||||
|
||||
/* Toggle expanded/collapsed state of bottom sheet */
|
||||
private fun toggleBottomSheetState() {
|
||||
when (bottomSheetBehavior.state) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> bottomSheetBehavior.state =
|
||||
BottomSheetBehavior.STATE_EXPANDED
|
||||
else -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: Custom LinearLayoutManager
|
||||
*/
|
||||
private inner class CustomLayoutManager(context: Context) :
|
||||
LinearLayoutManager(context, VERTICAL, false) {
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
}
|
||||
30
app/src/main/java/com/michatec/radio/ui/PlayerState.kt
Normal file
30
app/src/main/java/com/michatec/radio/ui/PlayerState.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* PlayerState.kt
|
||||
* Implements the PlayerState class
|
||||
* A PlayerState holds parameters describing the state of the player part of the UI
|
||||
*
|
||||
* This file is part of
|
||||
* TRANSISTOR - Radio App for Android
|
||||
*
|
||||
* Copyright (c) 2015-22 - Y20K.org
|
||||
* Licensed under the MIT-License
|
||||
* http://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
|
||||
package com.michatec.radio.ui
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.google.gson.annotations.Expose
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
||||
/*
|
||||
* PlayerState class
|
||||
*/
|
||||
@Parcelize
|
||||
data class PlayerState(
|
||||
@Expose var stationUuid: String = String(),
|
||||
@Expose var isPlaying: Boolean = false,
|
||||
@Expose var sleepTimerRunning: Boolean = false
|
||||
) : Parcelable
|
||||
11
app/src/main/res/anim/rotate_clockwise_slow.xml
Normal file
11
app/src/main/res/anim/rotate_clockwise_slow.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:shareInterpolator="false">
|
||||
<rotate
|
||||
android:duration="500"
|
||||
android:fromDegrees="0"
|
||||
android:pivotX="50%"
|
||||
android:pivotY="50%"
|
||||
android:toDegrees="360" />
|
||||
</set>
|
||||
11
app/src/main/res/anim/rotate_counterclockwise_fast.xml
Normal file
11
app/src/main/res/anim/rotate_counterclockwise_fast.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:shareInterpolator="false">
|
||||
<rotate
|
||||
android:duration="500"
|
||||
android:fromDegrees="0"
|
||||
android:pivotX="50%"
|
||||
android:pivotY="50%"
|
||||
android:toDegrees="-360" />
|
||||
</set>
|
||||
10
app/src/main/res/animator/scale_center_y.xml
Normal file
10
app/src/main/res/animator/scale_center_y.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<objectAnimator
|
||||
android:duration="350"
|
||||
android:propertyName="scaleY"
|
||||
android:repeatCount="infinite"
|
||||
android:repeatMode="reverse"
|
||||
android:valueFrom="0.2"
|
||||
android:valueTo="1" />
|
||||
</set>
|
||||
10
app/src/main/res/animator/scale_left_y.xml
Normal file
10
app/src/main/res/animator/scale_left_y.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<objectAnimator
|
||||
android:duration="250"
|
||||
android:propertyName="scaleY"
|
||||
android:repeatCount="infinite"
|
||||
android:repeatMode="reverse"
|
||||
android:valueFrom="0.2"
|
||||
android:valueTo="1" />
|
||||
</set>
|
||||
10
app/src/main/res/animator/scale_right_y.xml
Normal file
10
app/src/main/res/animator/scale_right_y.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<objectAnimator
|
||||
android:duration="300"
|
||||
android:propertyName="scaleY"
|
||||
android:repeatCount="infinite"
|
||||
android:repeatMode="reverse"
|
||||
android:valueFrom="0.2"
|
||||
android:valueTo="1" />
|
||||
</set>
|
||||
48
app/src/main/res/drawable-night/ic_audio_listening.xml
Normal file
48
app/src/main/res/drawable-night/ic_audio_listening.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="290.28dp"
|
||||
android:height="400dp"
|
||||
android:viewportWidth="290.29"
|
||||
android:viewportHeight="400">
|
||||
<path
|
||||
android:pathData="m0.35,400l290.29,0l-0,-1.96l-290.64,0z"
|
||||
android:fillColor="@color/player_button_buffering"/>
|
||||
<path
|
||||
android:pathData="M150.4,35.37m25.11,0a25.11,25.11 135,1 0,-50.23 0a25.11,25.11 45,1 0,50.23 0"
|
||||
android:fillColor="#FFDAA8A8"/>
|
||||
<path
|
||||
android:pathData="m152.01,176.17a11.25,11.25 0,0 0,-8.51 -14.47,10.69 10.69,0 0,0 -1.49,-0.18l-26.46,-42.46 24.16,-22.98a9.64,9.64 45,1 0,-13.41 -13.86l-33.36,32.9 -0.07,0.08a8.74,8.74 0,0 0,0.61 10.41l37.07,43.83a10.55,10.55 0,0 0,-0.28 1.07,11.25 11.25,0 0,0 10.1,13.41q0.48,0.04 0.95,0.04a11.29,11.29 0,0 0,10.69 -7.81z"
|
||||
android:fillColor="#FFDAA8A8"/>
|
||||
<path
|
||||
android:pathData="m191.73,384.91l12.54,0l5.96,-48.35l-18.5,0z"
|
||||
android:fillColor="#FFDAA8A8"/>
|
||||
<path
|
||||
android:pathData="m188.04,397.57l41.44,0l-0,-1.03a16.24,16.24 0,0 0,-16.25 -16.24l-25.2,0z"
|
||||
android:fillColor="@color/text_default"/>
|
||||
<path
|
||||
android:pathData="m67.12,367.57 l10.77,6.41 29.86,-38.5 -15.9,-9.47z"
|
||||
android:fillColor="#FFDAA8A8"/>
|
||||
<path
|
||||
android:pathData="m93.09,397.76 l0.52,-0.88a16.25,16.25 0,0 0,-5.65 -22.27l-21.65,-12.89 -8.83,14.83z"
|
||||
android:fillColor="@color/text_default"/>
|
||||
<path
|
||||
android:pathData="m211.83,363.61c8.4,-98.88 13.4,-190.75 -17.31,-228.22l-0.24,-0.29 -51.66,20.67 -0.09,0.18c-0.17,0.38 -17.36,38.17 -13.35,63.6l-12.74,58.62 -41.55,69.58a4.6,4.6 135,0 0,2.1 6.57l18.06,7.94a4.62,4.62 0,0 0,5.78 -1.81l45.2,-73.71 25.53,-56.53a1.53,1.53 0,0 1,2.92 0.47l14.15,133.03a4.59,4.59 45,0 0,4.58 4.11l14.04,0a4.63,4.63 0,0 0,4.59 -4.22z"
|
||||
android:fillColor="@color/player_button_buffering"/>
|
||||
<path
|
||||
android:pathData="m194.34,136.17 l0.25,-0.12 0.04,-0.27c1.93,-13.51 -0.35,-28.52 -6.79,-44.61a35.42,35.42 0,0 0,-41.11 -21.21,35.37 35.37,45 0,0 -22.56,17.35 34.99,34.99 45,0 0,-2.43 28.04c8.11,23.72 18.64,45.92 18.74,46.14l0.22,0.46z"
|
||||
android:fillColor="@color/text_default"/>
|
||||
<path
|
||||
android:pathData="m231.53,192.19a11.49,11.49 0,0 0,-8.23 -12.53l-48.31,-92.75a9.26,9.26 45,1 0,-15.75 9.72l49.45,92.1a11.16,11.16 0,0 0,-0.18 1.7,11.56 11.56,0 0,0 11.72,11.87 11.57,11.57 0,0 0,8.87 -4.35,11.43 11.43,135 0,0 2.44,-5.78z"
|
||||
android:fillColor="#FFDAA8A8"/>
|
||||
<path
|
||||
android:pathData="m180.83,21.19l-39.88,0l-0,-17.38c8.75,-3.48 17.32,-6.44 22.5,0a17.38,17.38 135,0 1,17.38 17.38z"
|
||||
android:fillColor="@color/player_button_buffering"/>
|
||||
<path
|
||||
android:pathData="m138.74,0.74c-23.84,0 -30.51,29.88 -30.51,46.74 -0,9.4 4.25,12.77 10.93,13.9l2.36,-12.59 5.53,13.13c1.88,0.01 3.85,-0.03 5.89,-0.06l1.87,-3.86 4.18,3.79c16.74,0.03 30.26,2.46 30.26,-14.31 -0,-16.86 -5.85,-46.74 -30.51,-46.74z"
|
||||
android:fillColor="@color/player_button_buffering"/>
|
||||
<path
|
||||
android:pathData="m145.85,25.36l-0,-23.25a2.1,2.1 45,0 1,2.1 -2.1l4.19,0a2.1,2.1 135,0 1,2.1 2.1l-0,21.85a13.26,13.26 135,1 1,-8.39 1.4z"
|
||||
android:fillColor="@color/text_default"/>
|
||||
<path
|
||||
android:pathData="M151.44,37.05m6.29,0a6.29,6.29 0,1 0,-12.58 0a6.29,6.29 0,1 0,12.58 0"
|
||||
android:fillColor="@color/player_button_buffering"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_add_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_add_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M18,13h-5v5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1v-5H6c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5V6c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v5h5c0.55,0 1,0.45 1,1s-0.45,1 -1,1z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_arrow_upward_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_arrow_upward_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/app_onboarding_icons"
|
||||
android:pathData="M13 19V7.83l4.88 4.88c0.39 0.39 1.03 0.39 1.42 0 0.39-0.39 0.39-1.02 0-1.41l-6.59-6.59c-0.39-0.39-1.02-0.39-1.41 0l-6.6 6.58c-0.39 0.39-0.39 1.02 0 1.41 0.39 0.39 1.02 0.39 1.41 0L11 7.83V19c0 0.55 0.45 1 1 1s1-0.45 1-1z" />
|
||||
</vector>
|
||||
48
app/src/main/res/drawable/ic_audio_listening.xml
Normal file
48
app/src/main/res/drawable/ic_audio_listening.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="290.28dp"
|
||||
android:height="400dp"
|
||||
android:viewportWidth="290.29"
|
||||
android:viewportHeight="400">
|
||||
<path
|
||||
android:pathData="m0.35,400l290.29,0l-0,-1.96l-290.64,0z"
|
||||
android:fillColor="@color/icon_default"/>
|
||||
<path
|
||||
android:pathData="M150.4,35.37m25.11,0a25.11,25.11 135,1 0,-50.23 0a25.11,25.11 45,1 0,50.23 0"
|
||||
android:fillColor="#FFD9BDBD"/>
|
||||
<path
|
||||
android:pathData="m152.01,176.17a11.25,11.25 0,0 0,-8.51 -14.47,10.69 10.69,0 0,0 -1.49,-0.18l-26.46,-42.46 24.16,-22.98a9.64,9.64 45,1 0,-13.41 -13.86l-33.36,32.9 -0.07,0.08a8.74,8.74 0,0 0,0.61 10.41l37.07,43.83a10.55,10.55 0,0 0,-0.28 1.07,11.25 11.25,0 0,0 10.1,13.41q0.48,0.04 0.95,0.04a11.29,11.29 0,0 0,10.69 -7.81z"
|
||||
android:fillColor="#FFD9BDBD"/>
|
||||
<path
|
||||
android:pathData="m191.73,384.91l12.54,0l5.96,-48.35l-18.5,0z"
|
||||
android:fillColor="#FFD9BDBD"/>
|
||||
<path
|
||||
android:pathData="m188.04,397.57l41.44,0l-0,-1.03a16.24,16.24 0,0 0,-16.25 -16.24l-25.2,0z"
|
||||
android:fillColor="@color/player_sheet_background"/>
|
||||
<path
|
||||
android:pathData="m67.12,367.57 l10.77,6.41 29.86,-38.5 -15.9,-9.47z"
|
||||
android:fillColor="#FFD9BDBD"/>
|
||||
<path
|
||||
android:pathData="m93.09,397.76 l0.52,-0.88a16.25,16.25 0,0 0,-5.65 -22.27l-21.65,-12.89 -8.83,14.83z"
|
||||
android:fillColor="@color/player_sheet_background"/>
|
||||
<path
|
||||
android:pathData="m211.83,363.61c8.4,-98.88 13.4,-190.75 -17.31,-228.22l-0.24,-0.29 -51.66,20.67 -0.09,0.18c-0.17,0.38 -17.36,38.17 -13.35,63.6l-12.74,58.62 -41.55,69.58a4.6,4.6 135,0 0,2.1 6.57l18.06,7.94a4.62,4.62 0,0 0,5.78 -1.81l45.2,-73.71 25.53,-56.53a1.53,1.53 0,0 1,2.92 0.47l14.15,133.03a4.59,4.59 45,0 0,4.58 4.11l14.04,0a4.63,4.63 0,0 0,4.59 -4.22z"
|
||||
android:fillColor="@color/icon_default"/>
|
||||
<path
|
||||
android:pathData="m194.34,136.17 l0.25,-0.12 0.04,-0.27c1.93,-13.51 -0.35,-28.52 -6.79,-44.61a35.42,35.42 0,0 0,-41.11 -21.21,35.37 35.37,45 0,0 -22.56,17.35 34.99,34.99 45,0 0,-2.43 28.04c8.11,23.72 18.64,45.92 18.74,46.14l0.22,0.46z"
|
||||
android:fillColor="@color/player_sheet_background"/>
|
||||
<path
|
||||
android:pathData="m231.53,192.19a11.49,11.49 0,0 0,-8.23 -12.53l-48.31,-92.75a9.26,9.26 45,1 0,-15.75 9.72l49.45,92.1a11.16,11.16 0,0 0,-0.18 1.7,11.56 11.56,0 0,0 11.72,11.87 11.57,11.57 0,0 0,8.87 -4.35,11.43 11.43,135 0,0 2.44,-5.78z"
|
||||
android:fillColor="#FFD9BDBD"/>
|
||||
<path
|
||||
android:pathData="m180.83,21.19l-39.88,0l-0,-17.38c8.75,-3.48 17.32,-6.44 22.5,0a17.38,17.38 135,0 1,17.38 17.38z"
|
||||
android:fillColor="@color/icon_default"/>
|
||||
<path
|
||||
android:pathData="m138.74,0.74c-23.84,0 -30.51,29.88 -30.51,46.74 -0,9.4 4.25,12.77 10.93,13.9l2.36,-12.59 5.53,13.13c1.88,0.01 3.85,-0.03 5.89,-0.06l1.87,-3.86 4.18,3.79c16.74,0.03 30.26,2.46 30.26,-14.31 -0,-16.86 -5.85,-46.74 -30.51,-46.74z"
|
||||
android:fillColor="@color/icon_default"/>
|
||||
<path
|
||||
android:pathData="m145.85,25.36l-0,-23.25a2.1,2.1 45,0 1,2.1 -2.1l4.19,0a2.1,2.1 135,0 1,2.1 2.1l-0,21.85a13.26,13.26 135,1 1,-8.39 1.4z"
|
||||
android:fillColor="@color/player_sheet_background"/>
|
||||
<path
|
||||
android:pathData="M151.44,37.05m6.29,0a6.29,6.29 0,1 0,-12.58 0a6.29,6.29 0,1 0,12.58 0"
|
||||
android:fillColor="@color/icon_default"/>
|
||||
</vector>
|
||||
31
app/src/main/res/drawable/ic_audio_waves_36dp.xml
Normal file
31
app/src/main/res/drawable/ic_audio_waves_36dp.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
|
||||
<group
|
||||
android:name="leftGroup"
|
||||
android:scaleY="0.5"
|
||||
android:pivotY="20">
|
||||
<path
|
||||
android:pathData="m6,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
|
||||
android:fillColor="@color/default_neutral_white" />
|
||||
</group>
|
||||
<group
|
||||
android:name="centerGroup"
|
||||
android:scaleY="0.5"
|
||||
android:pivotY="20">
|
||||
<path
|
||||
android:pathData="m12,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
|
||||
android:fillColor="@color/default_neutral_white" />
|
||||
</group>
|
||||
<group
|
||||
android:name="rightGroup"
|
||||
android:scaleY="0.5"
|
||||
android:pivotY="20">
|
||||
<path
|
||||
android:pathData="m18,4a2,2 45,0 1,2 2v14h-4v-14a2,2 135,0 1,2 -2z"
|
||||
android:fillColor="@color/default_neutral_white"/>
|
||||
</group>
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_audio_waves_animated.xml
Normal file
13
app/src/main/res/drawable/ic_audio_waves_animated.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/ic_audio_waves_36dp">
|
||||
<target
|
||||
android:name="leftGroup"
|
||||
android:animation="@animator/scale_left_y" />
|
||||
<target
|
||||
android:name="centerGroup"
|
||||
android:animation="@animator/scale_center_y" />
|
||||
<target
|
||||
android:name="rightGroup"
|
||||
android:animation="@animator/scale_right_y" />
|
||||
</animated-vector>
|
||||
9
app/src/main/res/drawable/ic_brush_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_brush_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M9.27,4.49c-1.63,7.54,3.75,12.41,7.66,13.8C15.54,19.38,13.81,20,12,20c-4.41,0-8-3.59-8-8C4,8.55,6.2,5.6,9.27,4.49 M11.99,2.01C6.4,2.01,2,6.54,2,12c0,5.52,4.48,10,10,10c3.71,0,6.93-2.02,8.66-5.02c-7.51-0.25-12.09-8.43-8.32-14.97 C12.22,2.01,12.11,2.01,11.99,2.01L11.99,2.01z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_check_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_check_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M9,16.17L5.53,12.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41l4.18,4.18c0.39,0.39 1.02,0.39 1.41,0L20.29,7.71c0.39,-0.39 0.39,-1.02 0,-1.41 -0.39,-0.39 -1.02,-0.39 -1.41,0L9,16.17z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_chevron_left_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_chevron_left_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M14.71 6.71c-0.39-0.39-1.02-0.39-1.41 0L8.71 11.3c-0.39 0.39-0.39 1.02 0 1.41l4.59 4.59c0.39 0.39 1.02 0.39 1.41 0 0.39-0.39 0.39-1.02 0-1.41L10.83 12l3.88-3.88c0.39-0.39 0.38-1.03 0-1.41z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_chevron_right_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_chevron_right_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M9.29 6.71c-0.39 0.39-0.39 1.02 0 1.41L13.17 12l-3.88 3.88c-0.39 0.39-0.39 1.02 0 1.41 0.39 0.39 1.02 0.39 1.41 0l4.59-4.59c0.39-0.39 0.39-1.02 0-1.41L10.7 6.7c-0.38-0.38-1.02-0.38-1.41 0.01z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_clear_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_clear_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_codeberg_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_codeberg_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m11.999,2.623c-5.54,0 -9.999,4.462 -9.999,10.001 -0,1.878 0.529,3.721 1.528,5.313l8.336,-10.778c0.06,-0.077 0.209,-0.077 0.27,0l3.482,4.502h-2.494l0.054,0.199h2.594l0.736,0.949h-3.062l0.086,0.311h3.217l0.647,0.836h-3.631l0.113,0.402h3.827l0.579,0.745h-4.198l0.137,0.49h4.438l0.508,0.655h-4.764l0.152,0.542h5.031l0.468,0.606h-5.33l0.152,0.542h5.598c0.998,-1.592 1.528,-3.435 1.528,-5.313 0,-5.54 -4.462,-10.001 -10.001,-10.001zM15.044,18.543 L15.196,19.085h4.438c0.145,-0.171 0.294,-0.361 0.428,-0.542zM15.365,19.69 L15.515,20.232h2.969c0.194,-0.166 0.405,-0.357 0.594,-0.542zM15.684,20.838 L15.836,21.377h1.003c0.296,-0.17 0.571,-0.343 0.868,-0.539z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_copy_content_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_copy_content_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M15,20H5V7c0,-0.55 -0.45,-1 -1,-1h0C3.45,6 3,6.45 3,7v13c0,1.1 0.9,2 2,2h10c0.55,0 1,-0.45 1,-1v0C16,20.45 15.55,20 15,20zM20,16V4c0,-1.1 -0.9,-2 -2,-2H9C7.9,2 7,2.9 7,4v12c0,1.1 0.9,2 2,2h9C19.1,18 20,17.1 20,16zM18,16H9V4h9V16z" />
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/ic_default_station_image_72dp.xml
Normal file
15
app/src/main/res/drawable/ic_default_station_image_72dp.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="72dp"
|
||||
android:height="72dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/icon_lightweight_background"
|
||||
android:pathData="M0,0h24v24h-24z" />
|
||||
|
||||
<path
|
||||
android:fillColor="@color/icon_lightweight"
|
||||
android:pathData="m22.5,13.5c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75c0,-4.969 -4.031,-9 -9,-9s-9,4.031 -9,9c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75c0,-5.801 4.699,-10.5 10.5,-10.5s10.5,4.699 10.5,10.5zM12,6.75c-3.727,0 -6.75,3.023 -6.75,6.75 0,0.414 0.336,0.75 0.75,0.75s0.75,-0.336 0.75,-0.75c0,-2.898 2.352,-5.25 5.25,-5.25s5.25,2.352 5.25,5.25c0,0.414 0.336,0.75 0.75,0.75s0.75,-0.336 0.75,-0.75c0,-3.727 -3.023,-6.75 -6.75,-6.75zM15,13.5c-0.004,1.363 -0.93,2.555 -2.25,2.895v3.855c0,0.414 -0.336,0.75 -0.75,0.75s-0.75,-0.336 -0.75,-0.75v-3.855c-1.512,-0.391 -2.469,-1.872 -2.207,-3.411s1.66,-2.617 3.215,-2.484c1.555,0.137 2.746,1.441 2.742,3zM13.5,13.5c0,-0.828 -0.672,-1.5 -1.5,-1.5s-1.5,0.672 -1.5,1.5 0.672,1.5 1.5,1.5 1.5,-0.672 1.5,-1.5z"
|
||||
android:strokeAlpha="0" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_download_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_download_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M16.59,9H15V4c0,-0.55 -0.45,-1 -1,-1h-4c-0.55,0 -1,0.45 -1,1v5H7.41c-0.89,0 -1.34,1.08 -0.71,1.71l4.59,4.59c0.39,0.39 1.02,0.39 1.41,0l4.59,-4.59c0.63,-0.63 0.19,-1.71 -0.7,-1.71zM5,19c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H6c-0.55,0 -1,0.45 -1,1z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_edit_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_edit_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M3,17.46v3.04c0,0.28 0.22,0.5 0.5,0.5h3.04c0.13,0 0.26,-0.05 0.35,-0.15L17.81,9.94l-3.75,-3.75L3.15,17.1c-0.1,0.1 -0.15,0.22 -0.15,0.36zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_favorite_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_favorite_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/list_card_mark_starred_star"
|
||||
android:pathData="M13.35 20.13c-0.76 0.69-1.93 0.69-2.69-0.01l-0.11-0.1C5.3 15.27 1.87 12.16 2 8.28c0.06-1.7 0.93-3.33 2.34-4.29 2.64-1.8 5.9-0.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41 0.96 2.28 2.59 2.34 4.29 0.14 3.88-3.3 6.99-8.55 11.76l-0.1 0.09z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_favorite_default_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_favorite_default_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M13.35 20.13c-0.76 0.69-1.93 0.69-2.69-0.01l-0.11-0.1C5.3 15.27 1.87 12.16 2 8.28c0.06-1.7 0.93-3.33 2.34-4.29 2.64-1.8 5.9-0.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41 0.96 2.28 2.59 2.34 4.29 0.14 3.88-3.3 6.99-8.55 11.76l-0.1 0.09z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_github_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_github_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_home_24dp.xml
Normal file
10
app/src/main/res/drawable/ic_home_24dp.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M220,780h150L370,530h220v250h150L740,390L480,195 220,390v390ZM220,840q-24.75,0 -42.38,-17.63T160,780L160,390q0,-14.25 6.38,-27T184,342l260,-195q8.3,-6 17.34,-9 9.05,-3 18.85,-3 9.8,0 18.72,3 8.91,3 17.09,9l260,195q11.25,8.25 17.63,21T800,390v390q0,24.75 -17.63,42.38T740,840L530,840L530,590L430,590v250L220,840ZM480,487Z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_image_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_image_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM18,19L6,19c-0.55,0 -1,-0.45 -1,-1L5,6c0,-0.55 0.45,-1 1,-1h12c0.55,0 1,0.45 1,1v12c0,0.55 -0.45,1 -1,1zM13.56,12.81l-2.35,3.02 -1.56,-1.88c-0.2,-0.25 -0.58,-0.24 -0.78,0.01l-1.74,2.23c-0.26,0.33 -0.02,0.81 0.39,0.81h8.98c0.41,0 0.65,-0.47 0.4,-0.8l-2.55,-3.39c-0.19,-0.26 -0.59,-0.26 -0.79,0z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_image_white_36dp.xml
Normal file
9
app/src/main/res/drawable/ic_image_white_36dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM18,19L6,19c-0.55,0 -1,-0.45 -1,-1L5,6c0,-0.55 0.45,-1 1,-1h12c0.55,0 1,0.45 1,1v12c0,0.55 -0.45,1 -1,1zM13.56,12.81l-2.35,3.02 -1.56,-1.88c-0.2,-0.25 -0.58,-0.24 -0.78,0.01l-1.74,2.23c-0.26,0.33 -0.02,0.81 0.39,0.81h8.98c0.41,0 0.65,-0.47 0.4,-0.8l-2.55,-3.39c-0.19,-0.26 -0.59,-0.26 -0.79,0z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_info_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_info_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
|
||||
</vector>
|
||||
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="40"
|
||||
android:viewportHeight="24">
|
||||
<group android:scaleX="0.42"
|
||||
android:scaleY="0.252"
|
||||
android:translateX="11.6"
|
||||
android:translateY="8.976">
|
||||
<path
|
||||
android:pathData="m2,0c-1.1,0 -2,0.9 -2,2v20c0,1.1 0.9,2 2,2h36c1.1,0 2,-0.9 2,-2v-20c0,-1.1 -0.9,-2 -2,-2h-36zM12,4a8,8 0,0 1,8 8,8 8,0 0,1 -8,8 8,8 0,0 1,-8 -8,8 8,0 0,1 8,-8zM25,5h9c1.1,0 2,0.9 2,2v5c0,1.1 -0.9,2 -2,2h-9c-1.1,0 -2,-0.9 -2,-2v-5c0,-1.1 0.9,-2 2,-2zM24.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5zM29.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5zM34.5,17a1.5,1.5 0,0 1,1.5 1.5,1.5 1.5,0 0,1 -1.5,1.5 1.5,1.5 0,0 1,-1.5 -1.5,1.5 1.5,0 0,1 1.5,-1.5z"
|
||||
android:fillColor="#f5cf87"/>
|
||||
<path
|
||||
android:pathData="m24.292,7.558h0.771v2.068q0,0.492 0.029,0.638 0.049,0.234 0.234,0.378 0.188,0.141 0.51,0.141 0.328,0 0.495,-0.133 0.167,-0.135 0.201,-0.331 0.034,-0.195 0.034,-0.648v-2.112h0.771v2.005q0,0.688 -0.063,0.971t-0.232,0.479q-0.167,0.195 -0.448,0.313 -0.281,0.115 -0.734,0.115 -0.547,0 -0.831,-0.125 -0.281,-0.128 -0.445,-0.328 -0.164,-0.203 -0.216,-0.424 -0.076,-0.328 -0.076,-0.969z"
|
||||
android:fillColor="#f5cf87"/>
|
||||
<path
|
||||
android:pathData="m28.151,11.376v-3.818h1.622q0.612,0 0.888,0.104 0.279,0.102 0.445,0.365 0.167,0.263 0.167,0.602 0,0.43 -0.253,0.711 -0.253,0.279 -0.755,0.352 0.25,0.146 0.411,0.32 0.164,0.174 0.44,0.62l0.466,0.745h-0.922l-0.557,-0.831q-0.297,-0.445 -0.406,-0.56 -0.109,-0.117 -0.232,-0.159 -0.122,-0.044 -0.388,-0.044h-0.156v1.594zM28.922,9.173h0.57q0.555,0 0.693,-0.047t0.216,-0.161q0.078,-0.115 0.078,-0.286 0,-0.193 -0.104,-0.31 -0.102,-0.12 -0.289,-0.151 -0.094,-0.013 -0.563,-0.013h-0.602z"
|
||||
android:fillColor="#f5cf87"/>
|
||||
<path
|
||||
android:pathData="m32.021,11.376v-3.786h0.771v3.143h1.917v0.643z"
|
||||
android:fillColor="#f5cf87"/>
|
||||
</group>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_library_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_library_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M12 9c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0-6c1.1 0 2 .9 2 2s-0.9 2-2 2-2-0.9-2-2 .9-2 2-2zm0 8.55C9.64 9.35 6.48 8 3 8v11c3.48 0 6.64 1.35 9 3.55 2.36-2.19 5.52-3.55 9-3.55V8c-3.48 0-6.64 1.35-9 3.55zm7 5.58c-2.53.34-4.93 1.3-7 2.82-2.06-1.52-4.47-2.49-7-2.83v-6.95c2.1.38 4.05 1.35 5.64 2.83L12 14.28l1.36-1.27c1.59-1.48 3.54-2.45 5.64-2.83v6.95z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_music_note_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_music_note_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M12,5v8.55c-0.94,-0.54 -2.1,-0.75 -3.33,-0.32 -1.34,0.48 -2.37,1.67 -2.61,3.07 -0.46,2.74 1.86,5.08 4.59,4.65 1.96,-0.31 3.35,-2.11 3.35,-4.1V7h2c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2h-2c-1.1,0 -2,0.9 -2,2z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_network_check_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_network_check_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M15.9,5c-0.17,0 -0.32,0.09 -0.41,0.23l-0.07,0.15 -5.18,11.65c-0.16,0.29 -0.26,0.61 -0.26,0.96 0,1.11 0.9,2.01 2.01,2.01 0.96,0 1.77,-0.68 1.96,-1.59l0.01,-0.03L16.4,5.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM2.06,10.06c0.51,0.51 1.33,0.55 1.89,0.09 2.76,-2.26 6.24,-3.18 9.58,-2.76l1.19,-2.68c-4.35,-0.78 -8.96,0.3 -12.57,3.25 -0.64,0.53 -0.68,1.51 -0.09,2.1zM21.94,10.06c0.59,-0.59 0.55,-1.57 -0.1,-2.1 -1.36,-1.11 -2.86,-1.95 -4.44,-2.53l-0.53,2.82c1.13,0.47 2.19,1.09 3.17,1.89 0.58,0.46 1.39,0.43 1.9,-0.08zM17.91,14.09c0.6,-0.6 0.56,-1.63 -0.14,-2.12 -0.46,-0.33 -0.94,-0.61 -1.44,-0.86l-0.55,2.92c0.11,0.07 0.22,0.14 0.32,0.22 0.57,0.4 1.33,0.32 1.81,-0.16zM6.08,14.08c0.5,0.5 1.27,0.54 1.85,0.13 0.94,-0.66 2.01,-1.06 3.1,-1.22l1.28,-2.88c-2.13,-0.06 -4.28,0.54 -6.09,1.84 -0.69,0.51 -0.74,1.53 -0.14,2.13z" />
|
||||
</vector>
|
||||
File diff suppressed because one or more lines are too long
9
app/src/main/res/drawable/ic_notification_play_36dp.xml
Normal file
9
app/src/main/res/drawable/ic_notification_play_36dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_notification_stop_36dp.xml
Normal file
9
app/src/main/res/drawable/ic_notification_stop_36dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M6,6h12v12H6z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_player_play_symbol_42dp.xml
Normal file
9
app/src/main/res/drawable/ic_player_play_symbol_42dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="42dp"
|
||||
android:height="42dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M8 6.82v10.36c0 0.79 0.87 1.27 1.54 0.84l8.14-5.18c 0.62-0.39 0.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M12,5v8.55c-0.94,-0.54 -2.1,-0.75 -3.33,-0.32 -1.34,0.48 -2.37,1.67 -2.61,3.07 -0.46,2.74 1.86,5.08 4.59,4.65 1.96,-0.31 3.35,-2.11 3.35,-4.1V7h2c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2h-2c-1.1,0 -2,0.9 -2,2z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_player_stop_symbol_36dp.xml
Normal file
9
app/src/main/res/drawable/ic_player_stop_symbol_36dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/default_neutral_white"
|
||||
android:pathData="M8,6h8c1.1,0 2,0.9 2,2v8c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2V8c0,-1.1 0.9,-2 2,-2z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_remove_circle_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_remove_circle_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/list_card_delete_icon"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM16,13L8,13c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h8c0.55,0 1,0.45 1,1s-0.45,1 -1,1z" />
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_save_m3u_24dp.xml
Normal file
18
app/src/main/res/drawable/ic_save_m3u_24dp.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m5.5,22q-0.6,0 -1.05,-0.45t-0.45,-1.05v-17q0,-0.6 0.45,-1.05t1.05,-0.45h8.4q0.311,0 0.593,0.125t0.482,0.325l4.575,4.575q0.2,0.2 0.325,0.482t0.125,0.593v12.4q0,0.6 -0.45,1.05t-1.05,0.45zM13.775,7.4v-3.9h-8.275v17h13v-12.35h-3.975q-0.319,0 -0.534,-0.216t-0.216,-0.534zM5.5,3.5v4.65,-4.65 17z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m7.713,14.054 l-0.988,-3.22l-0.025,0q0.006,0.115 0.017,0.348 0.014,0.23 0.025,0.491 0.011,0.261 0.011,0.472l0,1.909l-0.778,0l0,-4.105l1.185,0l0.971,3.139l0.017,0l1.03,-3.139l1.185,0l0,4.105l-0.811,0l0,-1.943q0,-0.194 0.006,-0.446 0.008,-0.253 0.02,-0.48 0.011,-0.23 0.017,-0.345l-0.025,0l-1.058,3.215z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m13.819,10.867q0,0.284 -0.118,0.491t-0.32,0.34q-0.199,0.132 -0.449,0.194l0,0.017q0.494,0.062 0.75,0.303 0.258,0.241 0.258,0.646 0,0.359 -0.177,0.643 -0.174,0.284 -0.539,0.446t-0.941,0.163q-0.34,0 -0.635,-0.056 -0.292,-0.053 -0.55,-0.166l0,-0.738q0.264,0.135 0.553,0.205 0.289,0.067 0.539,0.067 0.466,0 0.651,-0.16 0.188,-0.163 0.188,-0.455 0,-0.171 -0.087,-0.289 -0.087,-0.118 -0.303,-0.18 -0.213,-0.062 -0.598,-0.062l-0.312,0l0,-0.665l0.317,0q0.379,0 0.576,-0.07 0.199,-0.073 0.27,-0.197 0.073,-0.126 0.073,-0.286 0,-0.219 -0.135,-0.343 -0.135,-0.124 -0.449,-0.124 -0.197,0 -0.359,0.051 -0.16,0.048 -0.289,0.118 -0.129,0.067 -0.227,0.132l-0.401,-0.598q0.241,-0.174 0.564,-0.289 0.326,-0.115 0.775,-0.115 0.635,0 1.005,0.256t0.371,0.722z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m18.025,9.949l0,2.656q0,0.424 -0.188,0.766 -0.185,0.34 -0.564,0.539 -0.376,0.199 -0.949,0.199 -0.814,0 -1.241,-0.416 -0.427,-0.416 -0.427,-1.101l0,-2.645l0.868,0l0,2.513q0,0.508 0.208,0.713t0.615,0.205q0.286,0 0.463,-0.098 0.18,-0.098 0.264,-0.303 0.084,-0.205 0.084,-0.522l0,-2.507z" />
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_save_pls_24dp.xml
Normal file
18
app/src/main/res/drawable/ic_save_pls_24dp.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m5.5,22q-0.6,0 -1.05,-0.45t-0.45,-1.05v-17q0,-0.6 0.45,-1.05t1.05,-0.45h8.4q0.311,0 0.593,0.125t0.482,0.325l4.575,4.575q0.2,0.2 0.325,0.482t0.125,0.593v12.4q0,0.6 -0.45,1.05t-1.05,0.45zM13.775,7.4v-3.9h-8.275v17h13v-12.35h-3.975q-0.319,0 -0.534,-0.216t-0.216,-0.534zM5.5,3.5v4.65,-4.65 17z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m8.525,9.86q0.829,0 1.21,0.357 0.381,0.354 0.381,0.979 0,0.281 -0.085,0.539 -0.085,0.255 -0.278,0.454 -0.19,0.199 -0.51,0.316 -0.319,0.114 -0.788,0.114l-0.39,0l0,1.523l-0.908,0l0,-4.283zM8.479,10.604l-0.413,0l0,1.271l0.299,0q0.255,0 0.442,-0.067t0.29,-0.211 0.103,-0.369q0,-0.316 -0.176,-0.469 -0.176,-0.155 -0.545,-0.155z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m10.925,14.143l0,-4.283l0.908,0l0,3.533l1.737,0l0,0.75z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="m16.843,12.953q0,0.381 -0.185,0.662 -0.185,0.281 -0.539,0.434 -0.352,0.152 -0.855,0.152 -0.223,0 -0.437,-0.029 -0.211,-0.029 -0.407,-0.085 -0.193,-0.059 -0.369,-0.144l0,-0.844q0.305,0.135 0.633,0.243 0.328,0.108 0.65,0.108 0.223,0 0.357,-0.059 0.138,-0.059 0.199,-0.161t0.062,-0.234q0,-0.161 -0.108,-0.275t-0.299,-0.214q-0.188,-0.1 -0.425,-0.214 -0.149,-0.07 -0.325,-0.17 -0.176,-0.103 -0.334,-0.249t-0.261,-0.354q-0.1,-0.211 -0.1,-0.504 0,-0.384 0.176,-0.656t0.501,-0.416q0.328,-0.146 0.773,-0.146 0.334,0 0.636,0.079 0.305,0.076 0.636,0.223l-0.293,0.706q-0.296,-0.12 -0.53,-0.185 -0.234,-0.067 -0.478,-0.067 -0.17,0 -0.29,0.056 -0.12,0.053 -0.182,0.152 -0.062,0.097 -0.062,0.226 0,0.152 0.088,0.258 0.091,0.103 0.27,0.199 0.182,0.097 0.451,0.226 0.328,0.155 0.56,0.325 0.234,0.167 0.36,0.396 0.126,0.226 0.126,0.563z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_settings_24dp.xml
Normal file
10
app/src/main/res/drawable/ic_settings_24dp.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:pathData="M0 0h24v24H0V0z" />
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_share_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_share_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="@color/player_button_background"
|
||||
android:pathData="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 13.5v-7c0-0.41 0.47-0.65 0.8-0.4l4.67 3.5c 0.27 0.2 0.27 0.6 0 0.8l-4.67 3.5c-0.33 0.25-0.8.01-0.8-0.4z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_sleep_timer_24dp.xml
Normal file
11
app/src/main/res/drawable/ic_sleep_timer_24dp.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/player_sheet_icon"
|
||||
android:pathData="M14 1h-4c-0.55 0-1 0.45-1 1s0.45 1 1 1h4c0.55 0 1-0.45 1-1s-0.45-1-1-1zm-2 13c0.55 0 1-0.45 1-1V9c0-0.55-0.45-1-1-1s-1 0.45-1 1v4c0 0.55 0.45 1 1 1zm7.03-6.61l0.75-0.75c0.38-0.38 0.39 -1.01 0-1.4l-0.01-0.01c-0.39-0.39-1.01-0.38-1.4 0l-0.75 0.75 C16.07 4.74 14.12 4 12 4c-4.8 0-8.88 3.96-9 8.76C2.87 17.84 6.94 22 12 22c4.98 0 9-4.03 9-9 0-2.12-0.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"
|
||||
android:strokeColor="@color/player_sheet_icon" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_upload_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_upload_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/icon_default"
|
||||
android:pathData="M7.4,10h1.59v5c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-5h1.59c0.89,0 1.34,-1.08 0.71,-1.71L12.7,3.7c-0.39,-0.39 -1.02,-0.39 -1.41,0L6.7,8.29C6.07,8.92 6.51,10 7.4,10zM5,19c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H6C5.45,18 5,18.45 5,19z" />
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/selector_play_button.xml
Normal file
13
app/src/main/res/drawable/selector_play_button.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- View is "selected" -->
|
||||
<!-- <item android:state_selected="true"-->
|
||||
<!-- android:drawable="@drawable/ic_circular_button_playback_selected_56dp" />-->
|
||||
|
||||
<item android:drawable="@drawable/shape_player_button_small_selected" android:state_focused="true" />
|
||||
|
||||
<!-- Default state. -->
|
||||
<item android:drawable="@drawable/shape_player_button_small" />
|
||||
|
||||
</selector>
|
||||
10
app/src/main/res/drawable/selector_search_result_item.xml
Normal file
10
app/src/main/res/drawable/selector_search_result_item.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- View is "selected" -->
|
||||
<item android:drawable="@drawable/shape_search_result_item_selected" android:state_selected="true" />
|
||||
|
||||
<!-- Default state. -->
|
||||
<item android:drawable="@drawable/shape_search_result_item" />
|
||||
|
||||
</selector>
|
||||
6
app/src/main/res/drawable/shape_cover_small.xml
Normal file
6
app/src/main/res/drawable/shape_cover_small.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/list_card_cover_background" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/shape_player_button_small.xml
Normal file
7
app/src/main/res/drawable/shape_player_button_small.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<size
|
||||
android:width="56dp"
|
||||
android:height="56dp" />
|
||||
<solid android:color="@color/player_button_background" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="@color/default_neutral_lighter" />
|
||||
<size
|
||||
android:width="56dp"
|
||||
android:height="56dp" />
|
||||
<solid android:color="@color/player_button_background" />
|
||||
</shape>
|
||||
23
app/src/main/res/drawable/shape_player_sheet_background.xml
Normal file
23
app/src/main/res/drawable/shape_player_sheet_background.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:bottom="-4dp"
|
||||
android:left="-4dp"
|
||||
android:right="-4dp">
|
||||
|
||||
<shape android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/player_sheet_background" />
|
||||
<corners
|
||||
android:topLeftRadius="28dp"
|
||||
android:topRightRadius="28dp" />
|
||||
<stroke
|
||||
android:width="3dp"
|
||||
android:color="@color/list_card_stroke_background" />
|
||||
|
||||
</shape>
|
||||
|
||||
</item>
|
||||
|
||||
</layer-list>
|
||||
7
app/src/main/res/drawable/shape_search_result_item.xml
Normal file
7
app/src/main/res/drawable/shape_search_result_item.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/search_result_background" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="3dp" android:color="@color/list_card_stroke_background" />
|
||||
</shape>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user