baseline before import-songs feature
This commit is contained in:
commit
72e55ba319
2335 changed files with 59929 additions and 0 deletions
41
app/src/main/AndroidManifest.xml
Normal file
41
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".RadAmpApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.RadAmp">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.RadAmp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".player.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
7
app/src/main/java/com/rada/RadAmpApp.kt
Normal file
7
app/src/main/java/com/rada/RadAmpApp.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.rada
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class RadAmpApp : Application()
|
||||
382
app/src/main/java/com/rada/data/local/DefaultStations.kt
Normal file
382
app/src/main/java/com/rada/data/local/DefaultStations.kt
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package com.rada.data.local
|
||||
|
||||
import com.rada.domain.model.Station
|
||||
|
||||
object DefaultStations {
|
||||
|
||||
private val builtIn = listOf(
|
||||
// ── Lofi ──
|
||||
Station(
|
||||
id = "fluxfm-chillhop",
|
||||
name = "FLUX FM ChillHop",
|
||||
url = "http://streams.fluxfm.de/Chillhop/mp3-320",
|
||||
genre = "Lofi / Chillhop",
|
||||
description = "Chill beats and lofi hip-hop from FLUX FM Berlin",
|
||||
country = "DE",
|
||||
),
|
||||
Station(
|
||||
id = "lofi-24-7",
|
||||
name = "bigFM LoFi Focus",
|
||||
url = "https://stream.bigfm.de/lofifocus/mp3-128/radiobrowser",
|
||||
genre = "Lofi",
|
||||
description = "Lo-fi beats to focus and study — from bigFM",
|
||||
country = "DE",
|
||||
),
|
||||
Station(
|
||||
id = "chillofi-radio",
|
||||
name = "Chillofi Radio",
|
||||
url = "https://azc.rdstream-5677.dez.ovh/listen/chillofi/radio.mp3",
|
||||
genre = "Lofi / Chillhop",
|
||||
description = "Relax and study beats from France",
|
||||
country = "FR",
|
||||
),
|
||||
Station(
|
||||
id = "reyfm-lofi",
|
||||
name = "REYFM #LOFI",
|
||||
url = "https://listen.reyfm.de/lofi_320kbps.mp3",
|
||||
genre = "Lofi",
|
||||
description = "German lofi radio — chill vibes at 320kbps",
|
||||
country = "DE",
|
||||
),
|
||||
|
||||
// ── Synthwave ──
|
||||
Station(
|
||||
id = "synthwave-city-fm",
|
||||
name = "Synthwave City FM",
|
||||
url = "https://synthwave-rex.radioca.st/stream",
|
||||
genre = "Synthwave",
|
||||
description = "Retro synth, outrun, and neon-soaked beats",
|
||||
country = "NL",
|
||||
),
|
||||
Station(
|
||||
id = "synthwave-radio",
|
||||
name = "Synthwave Radio",
|
||||
url = "https://stream.synthwaveradio.eu/listen/synthwaveradio.eu/radio.mp3",
|
||||
genre = "Synthwave",
|
||||
description = "Synthwave, retrowave, and outrun",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "synthwave-neon-city",
|
||||
name = "Synthwave Neon City",
|
||||
url = "https://stream.laut.fm/synthwave",
|
||||
genre = "Synthwave",
|
||||
description = "Neon-drenched synthwave from laut.fm",
|
||||
country = "DE",
|
||||
),
|
||||
|
||||
// ── EDM ──
|
||||
Station(
|
||||
id = "electrodanceworld",
|
||||
name = "PulseEDM",
|
||||
url = "http://pulseedm.cdnstream1.com:8124/1373_128",
|
||||
genre = "EDM / Dance",
|
||||
description = "EDM, house, trance, dubstep — PulseEDM Radio",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "technolovers-edm",
|
||||
name = "Technolovers EDM",
|
||||
url = "https://stream.technolovers.fm/edm",
|
||||
genre = "EDM",
|
||||
description = "Club house, electro, and electronic dance music",
|
||||
country = "DE",
|
||||
),
|
||||
Station(
|
||||
id = "generation-dance",
|
||||
name = "Generation Dance Radio",
|
||||
url = "http://generationdance.lu/radio/8030/hd",
|
||||
genre = "EDM / Dance",
|
||||
description = "Eurodance, hands up, house, and trance",
|
||||
country = "LU",
|
||||
),
|
||||
|
||||
// ── Phonk ──
|
||||
Station(
|
||||
id = "phonkio-radio",
|
||||
name = "Phonk Radio",
|
||||
url = "https://stream.phonkioradio.com/listen/phonkio_radio/radio.mp3",
|
||||
genre = "Phonk",
|
||||
description = "24/7 phonk — drift, Memphis, and cowbells",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "badradio-phonk",
|
||||
name = "badradio 24/7",
|
||||
url = "https://s2.radio.co/s2b2b68744/listen",
|
||||
genre = "Phonk / Memphis",
|
||||
description = "OG phonk, Memphis, Houston, trap & trill",
|
||||
country = "NZ",
|
||||
),
|
||||
Station(
|
||||
id = "dopephonk",
|
||||
name = "DOPEPHONK",
|
||||
url = "https://listen.atomic.radio/dopephonk/middlequality",
|
||||
genre = "Phonk",
|
||||
description = "Pure phonk vibes 24/7",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── Classics / Classical ──
|
||||
Station(
|
||||
id = "classic-fm-uk",
|
||||
name = "CFMZ Classical FM",
|
||||
url = "https://live.amperwave.net/direct/mzmedia-cfmzfmmp3-ibc2",
|
||||
genre = "Classical",
|
||||
description = "Classical FM from Canada — baroque to romantic",
|
||||
country = "CA",
|
||||
),
|
||||
Station(
|
||||
id = "your-classical-relax",
|
||||
name = "Your Classical - Relax",
|
||||
url = "http://relax.stream.publicradio.org/relax.mp3",
|
||||
genre = "Classical / Relax",
|
||||
description = "Peaceful classical music from American Public Media",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "classical-piano",
|
||||
name = "Peaceful Piano",
|
||||
url = "https://holiday.stream.publicradio.org/peacefulpiano.mp3",
|
||||
genre = "Classical Piano",
|
||||
description = "Peaceful classical piano — Chopin, Debussy, Einaudi",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "classical-guitar-181",
|
||||
name = "181.FM - Classical Guitar",
|
||||
url = "https://listen.181fm.com/181-classicalguitar_128k.mp3",
|
||||
genre = "Classical Guitar",
|
||||
description = "Classical and Spanish guitar from 181.FM",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "epic-classical-piano",
|
||||
name = "EPIC CLASSICAL - Piano",
|
||||
url = "https://stream.epic-classical.com/classical-piano",
|
||||
genre = "Classical Piano",
|
||||
description = "Ambient, chill, and relaxing classical piano",
|
||||
country = "DE",
|
||||
),
|
||||
|
||||
// ── Strings / Ambient ──
|
||||
Station(
|
||||
id = "soma-fm-lush",
|
||||
name = "Lush",
|
||||
url = "https://ice1.somafm.com/lush-128-mp3",
|
||||
genre = "Strings / Ambient",
|
||||
description = "Sensuous and mellow vocals with lush string textures",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-dronezone",
|
||||
name = "Drone Zone",
|
||||
url = "https://ice1.somafm.com/dronezone-128-mp3",
|
||||
genre = "Ambient / Strings",
|
||||
description = "Atmospheric textures with beats and samples",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── Electronic / NCS ──
|
||||
Station(
|
||||
id = "ncs-radio",
|
||||
name = "NCS Radio",
|
||||
url = "https://radio.cliamp.stream/ncs/stream",
|
||||
genre = "Electronic / NCS",
|
||||
description = "NoCopyrightSounds — unlimited gaming music",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── cliamp.stream ──
|
||||
Station(
|
||||
id = "cliamp-lofi",
|
||||
name = "Lofi Hip Hop Radio",
|
||||
url = "https://radio.cliamp.stream/lofi/stream",
|
||||
genre = "Lofi / Hip-Hop",
|
||||
description = "24/7 lofi hip-hop beats to chill and code to — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "cliamp-edm",
|
||||
name = "EDM Pulse",
|
||||
url = "https://radio.cliamp.stream/edm/stream",
|
||||
genre = "EDM / Dance",
|
||||
description = "Non-stop EDM pulse — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "cliamp-synthwave",
|
||||
name = "Synthwave Nights",
|
||||
url = "https://radio.cliamp.stream/synthwave/stream",
|
||||
genre = "Synthwave",
|
||||
description = "Retro synthwave nights — neon drives at midnight, from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "cliamp-omarchy",
|
||||
name = "Omarchy Radio",
|
||||
url = "https://radio.cliamp.stream/omarchy/stream",
|
||||
genre = "Electronic / Community",
|
||||
description = "The Omarchy Linux community radio station — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-house",
|
||||
name = "NCS House",
|
||||
url = "https://radio.cliamp.stream/ncs-house/stream",
|
||||
genre = "Electronic / House",
|
||||
description = "NoCopyrightSounds house mixes — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-dubstep",
|
||||
name = "NCS Dubstep",
|
||||
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
|
||||
genre = "Electronic / Dubstep",
|
||||
description = "NoCopyrightSounds dubstep — heavy bass drops, from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-dnb",
|
||||
name = "NCS Drum & Bass",
|
||||
url = "https://radio.cliamp.stream/ncs-dnb/stream",
|
||||
genre = "Electronic / Drum & Bass",
|
||||
description = "NoCopyrightSounds drum & bass — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-trap",
|
||||
name = "NCS Trap",
|
||||
url = "https://radio.cliamp.stream/ncs-trap/stream",
|
||||
genre = "Electronic / Trap",
|
||||
description = "NoCopyrightSounds trap beats — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-phonk",
|
||||
name = "NCS Phonk",
|
||||
url = "https://radio.cliamp.stream/ncs-phonk/stream",
|
||||
genre = "Phonk",
|
||||
description = "NoCopyrightSounds phonk — drift phonk and cowbells, from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-pop",
|
||||
name = "NCS Pop",
|
||||
url = "https://radio.cliamp.stream/ncs-pop/stream",
|
||||
genre = "Electronic / Pop",
|
||||
description = "NoCopyrightSounds pop hits — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "ncs-chill",
|
||||
name = "NCS Chill",
|
||||
url = "https://radio.cliamp.stream/ncs-chill/stream",
|
||||
genre = "Electronic / Chill",
|
||||
description = "NoCopyrightSounds chill mixes — from cliamp.stream",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── SomaFM classics ──
|
||||
Station(
|
||||
id = "soma-fm-groovesalad",
|
||||
name = "Groove Salad",
|
||||
url = "https://ice1.somafm.com/groovesalad-128-mp3",
|
||||
genre = "Ambient / Downtempo",
|
||||
description = "A nicely chilled plate of ambient beats and grooves",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-spacestation",
|
||||
name = "Space Station Soma",
|
||||
url = "https://ice1.somafm.com/spacestation-128-mp3",
|
||||
genre = "Ambient / Electronica",
|
||||
description = "Tune in, turn on, drift away",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-defcon",
|
||||
name = "DEF CON",
|
||||
url = "https://ice1.somafm.com/defcon-128-mp3",
|
||||
genre = "Electronic / Hacker",
|
||||
description = "The DEF CON hacker convention radio",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-thetrip",
|
||||
name = "The Trip",
|
||||
url = "https://ice1.somafm.com/thetrip-128-mp3",
|
||||
genre = "House / Techno",
|
||||
description = "Progressive house / trance. Tip top tunes.",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-fluid",
|
||||
name = "Fluid",
|
||||
url = "https://ice1.somafm.com/fluid-128-mp3",
|
||||
genre = "Instrumental Hip-Hop",
|
||||
description = "Instrumental hip-hop beats and grooves",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "soma-fm-seventies",
|
||||
name = "Left Coast 70s",
|
||||
url = "https://ice1.somafm.com/seventies-128-mp3",
|
||||
genre = "Retro / 70s",
|
||||
description = "Mellow grooves from the 1970s",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── Classic Rock ──
|
||||
Station(
|
||||
id = "rdmix-classic-rock",
|
||||
name = "RdMix Classic Rock",
|
||||
url = "https://cast1.torontocast.com:4610/stream",
|
||||
genre = "Classic Rock",
|
||||
description = "Classic rock from the 70s, 80s, and 90s",
|
||||
country = "CA",
|
||||
),
|
||||
|
||||
// ── Oldies ──
|
||||
Station(
|
||||
id = "good-time-oldies",
|
||||
name = "Good Time Oldies",
|
||||
url = "http://listen.181fm.com/181-goodtime_128k.mp3",
|
||||
genre = "Oldies",
|
||||
description = "The greatest oldies from the 50s and 60s",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── Jazz ──
|
||||
Station(
|
||||
id = "smooth-jazz-101",
|
||||
name = "101 Smooth Jazz",
|
||||
url = "http://jking.cdnstream1.com/b22139_128mp3",
|
||||
genre = "Jazz / Smooth Jazz",
|
||||
description = "Smooth jazz — saxophone, keys, and chill grooves",
|
||||
country = "US",
|
||||
),
|
||||
|
||||
// ── Eclectic / Funk / Hip-Hop ──
|
||||
Station(
|
||||
id = "kcrw-eclectic",
|
||||
name = "KCRW Eclectic 24",
|
||||
url = "https://streams.kcrw.com/e24_mp3",
|
||||
genre = "Eclectic / Funk / Hip-Hop / Jazz",
|
||||
description = "Eclectic mix — funk, hip-hop, jazz, alternative, experimental",
|
||||
country = "US",
|
||||
),
|
||||
Station(
|
||||
id = "radio-paradise-eclectic",
|
||||
name = "Radio Paradise Eclectic",
|
||||
url = "http://stream.radioparadise.com/eclectic-320",
|
||||
genre = "Eclectic / World / Rock / Funk",
|
||||
description = "Eclectic world mix — rock, funk, world, and everything in between",
|
||||
country = "US",
|
||||
),
|
||||
)
|
||||
|
||||
fun getAll(): List<Station> = builtIn
|
||||
|
||||
fun getById(id: String): Station? = builtIn.find { it.id == id }
|
||||
}
|
||||
61
app/src/main/java/com/rada/data/local/RadAmpDatabase.kt
Normal file
61
app/src/main/java/com/rada/data/local/RadAmpDatabase.kt
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package com.rada.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rada.data.local.dao.PlaylistDao
|
||||
import com.rada.data.local.dao.SavedSongDao
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.local.entity.PlaylistSongEntity
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
|
||||
@Database(
|
||||
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
|
||||
version = 2,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class RadAmpDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun savedSongDao(): SavedSongDao
|
||||
abstract fun playlistDao(): PlaylistDao
|
||||
|
||||
companion object {
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
playlistId INTEGER NOT NULL,
|
||||
songId INTEGER NOT NULL,
|
||||
addedAt INTEGER NOT NULL,
|
||||
PRIMARY KEY(playlistId, songId),
|
||||
FOREIGN KEY(playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(songId) REFERENCES saved_songs(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_songId ON playlist_songs(songId)")
|
||||
}
|
||||
}
|
||||
|
||||
fun create(context: Context): RadAmpDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RadAmpDatabase::class.java,
|
||||
"radamp.db",
|
||||
).addMigrations(MIGRATION_1_2).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt
Normal file
54
app/src/main/java/com/rada/data/local/dao/PlaylistDao.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package com.rada.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.local.entity.PlaylistSongEntity
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PlaylistDao {
|
||||
|
||||
@Query("SELECT * FROM playlists ORDER BY createdAt DESC")
|
||||
fun getAllPlaylists(): Flow<List<PlaylistEntity>>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE id = :playlistId")
|
||||
suspend fun getPlaylistById(playlistId: Long): PlaylistEntity?
|
||||
|
||||
@Insert
|
||||
suspend fun createPlaylist(playlist: PlaylistEntity): Long
|
||||
|
||||
@Delete
|
||||
suspend fun deletePlaylist(playlist: PlaylistEntity)
|
||||
|
||||
@Query("DELETE FROM playlists WHERE id = :playlistId")
|
||||
suspend fun deletePlaylistById(playlistId: Long)
|
||||
|
||||
@Insert
|
||||
suspend fun addSongToPlaylist(playlistSong: PlaylistSongEntity)
|
||||
|
||||
@Query("DELETE FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId")
|
||||
suspend fun removeSongFromPlaylist(playlistId: Long, songId: Long)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM saved_songs s
|
||||
INNER JOIN playlist_songs ps ON s.id = ps.songId
|
||||
WHERE ps.playlistId = :playlistId
|
||||
ORDER BY ps.addedAt DESC
|
||||
"""
|
||||
)
|
||||
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
|
||||
fun getPlaylistSongCount(playlistId: Long): Flow<Int>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId)")
|
||||
suspend fun isSongInPlaylist(playlistId: Long, songId: Long): Boolean
|
||||
|
||||
@Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId")
|
||||
suspend fun getPlaylistIdsForSong(songId: Long): List<Long>
|
||||
}
|
||||
52
app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt
Normal file
52
app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package com.rada.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SavedSongDao {
|
||||
|
||||
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
|
||||
fun getAllByDate(): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
|
||||
fun getAllByArtist(): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT * FROM saved_songs ORDER BY title ASC")
|
||||
fun getAllByTitle(): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT * FROM saved_songs WHERE isFavorite = 1 ORDER BY dateSaved DESC")
|
||||
fun getFavorites(): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT * FROM saved_songs WHERE artist LIKE '%' || :query || '%' OR title LIKE '%' || :query || '%' OR stationName LIKE '%' || :query || '%'")
|
||||
fun search(query: String): Flow<List<SavedSongEntity>>
|
||||
|
||||
@Query("SELECT * FROM saved_songs WHERE id = :id")
|
||||
suspend fun getById(id: Long): SavedSongEntity?
|
||||
|
||||
@Query("SELECT * FROM saved_songs WHERE filePath = :path LIMIT 1")
|
||||
suspend fun getByPath(path: String): SavedSongEntity?
|
||||
|
||||
@Insert
|
||||
suspend fun insert(song: SavedSongEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(song: SavedSongEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(song: SavedSongEntity)
|
||||
|
||||
@Query("DELETE FROM saved_songs WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM saved_songs")
|
||||
fun getCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC LIMIT :limit")
|
||||
fun getRecentlySaved(limit: Int = 20): Flow<List<SavedSongEntity>>
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.rada.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "playlists")
|
||||
data class PlaylistEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val name: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.rada.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
|
||||
@Entity(
|
||||
tableName = "playlist_songs",
|
||||
primaryKeys = ["playlistId", "songId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = PlaylistEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["playlistId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = SavedSongEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index("songId"),
|
||||
],
|
||||
)
|
||||
data class PlaylistSongEntity(
|
||||
val playlistId: Long,
|
||||
val songId: Long,
|
||||
val addedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.rada.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "saved_songs")
|
||||
data class SavedSongEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val fileName: String,
|
||||
val filePath: String,
|
||||
val artist: String,
|
||||
val title: String,
|
||||
val album: String,
|
||||
val stationName: String,
|
||||
val durationMs: Long = 0,
|
||||
val fileSize: Long = 0,
|
||||
val format: String = "mp3",
|
||||
val dateSaved: Long = System.currentTimeMillis(),
|
||||
val isFavorite: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.rada.data.repository
|
||||
|
||||
import com.rada.data.local.dao.SavedSongDao
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class SortOrder {
|
||||
DATE_SAVED,
|
||||
ARTIST,
|
||||
TITLE,
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class LibraryRepository @Inject constructor(
|
||||
private val savedSongDao: SavedSongDao,
|
||||
) {
|
||||
|
||||
fun getAll(sortOrder: SortOrder = SortOrder.DATE_SAVED): Flow<List<SavedSongEntity>> {
|
||||
return when (sortOrder) {
|
||||
SortOrder.DATE_SAVED -> savedSongDao.getAllByDate()
|
||||
SortOrder.ARTIST -> savedSongDao.getAllByArtist()
|
||||
SortOrder.TITLE -> savedSongDao.getAllByTitle()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFavorites(): Flow<List<SavedSongEntity>> = savedSongDao.getFavorites()
|
||||
|
||||
fun search(query: String): Flow<List<SavedSongEntity>> = savedSongDao.search(query)
|
||||
|
||||
fun getRecentlySaved(limit: Int = 20): Flow<List<SavedSongEntity>> =
|
||||
savedSongDao.getRecentlySaved(limit)
|
||||
|
||||
fun getCount(): Flow<Int> = savedSongDao.getCount()
|
||||
|
||||
suspend fun getById(id: Long): SavedSongEntity? = savedSongDao.getById(id)
|
||||
|
||||
suspend fun getByPath(path: String): SavedSongEntity? = savedSongDao.getByPath(path)
|
||||
|
||||
suspend fun insert(song: SavedSongEntity): Long = savedSongDao.insert(song)
|
||||
|
||||
suspend fun update(song: SavedSongEntity) = savedSongDao.update(song)
|
||||
|
||||
suspend fun toggleFavorite(id: Long) {
|
||||
val song = savedSongDao.getById(id) ?: return
|
||||
savedSongDao.update(song.copy(isFavorite = !song.isFavorite))
|
||||
}
|
||||
|
||||
suspend fun delete(id: Long) = savedSongDao.deleteById(id)
|
||||
|
||||
suspend fun delete(song: SavedSongEntity) = savedSongDao.delete(song)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.rada.data.repository
|
||||
|
||||
import com.rada.data.local.dao.PlaylistDao
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.local.entity.PlaylistSongEntity
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PlaylistRepository @Inject constructor(
|
||||
private val playlistDao: PlaylistDao,
|
||||
) {
|
||||
|
||||
fun getAllPlaylists(): Flow<List<PlaylistEntity>> = playlistDao.getAllPlaylists()
|
||||
|
||||
suspend fun getPlaylistById(id: Long): PlaylistEntity? = playlistDao.getPlaylistById(id)
|
||||
|
||||
suspend fun createPlaylist(name: String): Long {
|
||||
return playlistDao.createPlaylist(PlaylistEntity(name = name))
|
||||
}
|
||||
|
||||
suspend fun deletePlaylist(playlist: PlaylistEntity) = playlistDao.deletePlaylist(playlist)
|
||||
|
||||
suspend fun deletePlaylistById(id: Long) = playlistDao.deletePlaylistById(id)
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: Long, songId: Long) {
|
||||
playlistDao.addSongToPlaylist(
|
||||
PlaylistSongEntity(playlistId = playlistId, songId = songId)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeSongFromPlaylist(playlistId: Long, songId: Long) {
|
||||
playlistDao.removeSongFromPlaylist(playlistId, songId)
|
||||
}
|
||||
|
||||
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>> {
|
||||
return playlistDao.getSongsInPlaylist(playlistId)
|
||||
}
|
||||
|
||||
fun getPlaylistSongCount(playlistId: Long): Flow<Int> {
|
||||
return playlistDao.getPlaylistSongCount(playlistId)
|
||||
}
|
||||
|
||||
suspend fun isSongInPlaylist(playlistId: Long, songId: Long): Boolean {
|
||||
return playlistDao.isSongInPlaylist(playlistId, songId)
|
||||
}
|
||||
|
||||
suspend fun getPlaylistIdsForSong(songId: Long): List<Long> {
|
||||
return playlistDao.getPlaylistIdsForSong(songId)
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/com/rada/data/repository/StationRepository.kt
Normal file
131
app/src/main/java/com/rada/data/repository/StationRepository.kt
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package com.rada.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.rada.data.local.DefaultStations
|
||||
import com.rada.domain.model.Station
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StationRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("rada_stations", Context.MODE_PRIVATE)
|
||||
|
||||
private val stations = mutableListOf<Station>()
|
||||
private val nameOverrides = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
nameOverrides.putAll(loadNameOverrides())
|
||||
rebuild()
|
||||
}
|
||||
|
||||
fun getAll(): List<Station> = stations.toList()
|
||||
|
||||
fun getById(id: String): Station? = stations.find { it.id == id }
|
||||
|
||||
fun addStation(station: Station) {
|
||||
stations.add(station)
|
||||
saveCustomStations()
|
||||
}
|
||||
|
||||
fun removeStation(id: String) {
|
||||
stations.removeAll { it.id == id }
|
||||
if (nameOverrides.remove(id) != null) saveNameOverrides()
|
||||
saveCustomStations()
|
||||
}
|
||||
|
||||
fun isCustomStation(id: String): Boolean {
|
||||
return stations.any { it.id == id } && !DefaultStations.getAll().any { it.id == id }
|
||||
}
|
||||
|
||||
fun renameStation(id: String, newName: String) {
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.isEmpty() || stations.none { it.id == id }) return
|
||||
nameOverrides[id] = trimmed
|
||||
rebuild()
|
||||
saveNameOverrides()
|
||||
}
|
||||
|
||||
fun resetStationName(id: String) {
|
||||
if (nameOverrides.remove(id) == null) return
|
||||
rebuild()
|
||||
saveNameOverrides()
|
||||
}
|
||||
|
||||
fun isRenamed(id: String): Boolean = nameOverrides.containsKey(id)
|
||||
|
||||
fun getOriginalStationName(id: String): String? =
|
||||
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
|
||||
|
||||
private fun rebuild() {
|
||||
stations.clear()
|
||||
stations.addAll(DefaultStations.getAll())
|
||||
stations.addAll(loadCustomStations())
|
||||
for (i in stations.indices) {
|
||||
val station = stations[i]
|
||||
nameOverrides[station.id]?.let { override ->
|
||||
stations[i] = station.copy(name = override)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNameOverrides(): Map<String, String> {
|
||||
val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNameOverrides() {
|
||||
val obj = JSONObject()
|
||||
nameOverrides.forEach { (id, name) -> obj.put(id, name) }
|
||||
prefs.edit().putString(KEY_NAME_OVERRIDES, obj.toString()).apply()
|
||||
}
|
||||
|
||||
private fun loadCustomStations(): List<Station> {
|
||||
val json = prefs.getString(KEY_CUSTOM_STATIONS, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
Station(
|
||||
id = obj.getString("id"),
|
||||
name = obj.getString("name"),
|
||||
url = obj.getString("url"),
|
||||
genre = obj.optString("genre", "Custom"),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveCustomStations() {
|
||||
val custom = stations.filter { isCustomStation(it.id) }
|
||||
val arr = JSONArray()
|
||||
custom.forEach { s ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", s.id)
|
||||
put("name", s.name)
|
||||
put("url", s.url)
|
||||
put("genre", s.genre)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
||||
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/com/rada/data/repository/StreamFetcher.kt
Normal file
147
app/src/main/java/com/rada/data/repository/StreamFetcher.kt
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package com.rada.data.repository
|
||||
|
||||
import com.rada.domain.model.StreamEntry
|
||||
import java.net.URLDecoder
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Turns a user-supplied URL into [StreamEntry]s.
|
||||
*
|
||||
* - A playlist (m3u / m3u8 / pls) is fetched over HTTP and expanded into
|
||||
* its contained stream entries.
|
||||
* - Anything else is treated as a direct stream URL.
|
||||
*/
|
||||
@Singleton
|
||||
class StreamFetcher @Inject constructor() {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
|
||||
suspend fun fetch(userUrl: String): List<StreamEntry> = withContext(Dispatchers.IO) {
|
||||
val trimmed = userUrl.trim()
|
||||
if (trimmed.isBlank()) return@withContext emptyList()
|
||||
val normalized = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) trimmed
|
||||
else "http://$trimmed"
|
||||
|
||||
if (classify(normalized) == "playlist") {
|
||||
val body = download(normalized)
|
||||
parsePlaylist(body, normalized)
|
||||
} else {
|
||||
listOf(directEntry(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
private fun classify(url: String): String {
|
||||
val path = try {
|
||||
java.net.URI(url).path?.lowercase(Locale.ROOT).orEmpty()
|
||||
} catch (_: Exception) {
|
||||
url.substringAfter("://").substringAfter("/").substringBefore("?").lowercase(Locale.ROOT)
|
||||
}
|
||||
return when {
|
||||
path.endsWith(".m3u") || path.endsWith(".m3u8") || path.endsWith(".pls") -> "playlist"
|
||||
else -> "stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(url: String): String {
|
||||
val request = Request.Builder().url(url).header("User-Agent", "RadAmp/1.0").build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch playlist: ${response.code}")
|
||||
return response.body?.string().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePlaylist(body: String, sourceUrl: String): List<StreamEntry> {
|
||||
val fileName = sourceUrl.substringAfterLast("/").substringBefore("?")
|
||||
.removeSuffix(".m3u8").removeSuffix(".m3u").removeSuffix(".pls")
|
||||
val host = sourceUrl.substringAfter("://").substringBefore("/")
|
||||
val sourceName = fileName.ifEmpty { host }
|
||||
val lines = body.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val entries = mutableListOf<StreamEntry>()
|
||||
var pendingName: String? = null
|
||||
|
||||
for (line in lines) {
|
||||
when {
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
// #EXTINF:-1,Artist - Title
|
||||
val comma = line.indexOf(',')
|
||||
pendingName = if (comma >= 0) line.substring(comma + 1).trim() else null
|
||||
}
|
||||
line.startsWith("#EXTM3U") || line.startsWith("#EXT") -> {
|
||||
// other directives (EXTM3U, EXTGRP, EXTMCAT...) - ignore
|
||||
}
|
||||
line.startsWith("#") -> {
|
||||
// unknown comment - ignore
|
||||
}
|
||||
line.startsWith("http://") || line.startsWith("https://") -> {
|
||||
entries += entryFor(line, pendingName ?: defaultName(line), sourceName)
|
||||
pendingName = null
|
||||
}
|
||||
else -> {
|
||||
// relative entry - try to resolve against the playlist URL
|
||||
val resolved = try {
|
||||
java.net.URI(sourceUrl).resolve(line).toString()
|
||||
} catch (_: Exception) {
|
||||
line
|
||||
}
|
||||
if (resolved.startsWith("http")) {
|
||||
entries += entryFor(resolved, pendingName ?: defaultName(resolved), sourceName)
|
||||
}
|
||||
pendingName = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PLS format: [internet radio stream] + NumberofStreams + FileN + TitleN
|
||||
if (entries.isEmpty() && Regex("(?im)^File\\d+\\s*=").containsMatchIn(body)) {
|
||||
val fileRe = Regex("(?im)^File(\\d+)\\s*=\\s*(.+)$")
|
||||
val titleRe = Regex("(?im)^Title(\\d+)\\s*=\\s*(.+)$")
|
||||
val titles = titleRe.findAll(body).associate { m ->
|
||||
m.groupValues[1].trim() to m.groupValues[2].trim()
|
||||
}
|
||||
fileRe.findAll(body).forEach { m ->
|
||||
val n = m.groupValues[1]
|
||||
val url = m.groupValues[2].trim()
|
||||
if (url.startsWith("http")) {
|
||||
entries += entryFor(url, titles[n] ?: defaultName(url), sourceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun entryFor(url: String, name: String, source: String): StreamEntry =
|
||||
StreamEntry(
|
||||
id = url,
|
||||
name = name,
|
||||
url = url,
|
||||
source = source,
|
||||
group = "From $source"
|
||||
)
|
||||
|
||||
private fun defaultName(url: String): String {
|
||||
return try {
|
||||
// host[:port] is the most recognizable label for a bare stream URL
|
||||
val host = url.substringAfter("://").substringBefore("/")
|
||||
URLDecoder.decode(host, "UTF-8").ifEmpty { url }
|
||||
} catch (_: Exception) {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
private fun directEntry(url: String): StreamEntry {
|
||||
val host = url.substringAfter("://").substringBefore("/")
|
||||
return StreamEntry(
|
||||
id = url,
|
||||
name = host,
|
||||
url = url,
|
||||
source = "Direct",
|
||||
group = "Direct URLs"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.rada.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.rada.domain.model.StreamEntry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StreamRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs by lazy {
|
||||
context.getSharedPreferences("rada_streams", Context.MODE_PRIVATE)
|
||||
}
|
||||
private val key = "stream_entries"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val streamEntriesSerializer = ListSerializer(StreamEntry.serializer())
|
||||
|
||||
private var cache: List<StreamEntry>? = null
|
||||
|
||||
fun getAll(): List<StreamEntry> {
|
||||
cache?.let { return it }
|
||||
val raw = prefs.getString(key, "[]") ?: "[]"
|
||||
val list = try {
|
||||
json.decodeFromString(streamEntriesSerializer, raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
cache = list
|
||||
return list
|
||||
}
|
||||
|
||||
fun addAll(entries: List<StreamEntry>) {
|
||||
val existing = getAll().associateBy { it.url }.toMutableMap()
|
||||
for (e in entries) {
|
||||
existing.putIfAbsent(e.url, e)
|
||||
}
|
||||
save(existing.values.toList())
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
save(getAll().filterNot { it.id == id })
|
||||
}
|
||||
|
||||
fun rename(id: String, newName: String) {
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
save(getAll().map { entry ->
|
||||
if (entry.id == id) entry.copy(name = trimmed) else entry
|
||||
})
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
save(emptyList())
|
||||
}
|
||||
|
||||
private fun save(list: List<StreamEntry>) {
|
||||
cache = list
|
||||
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
|
||||
}
|
||||
}
|
||||
64
app/src/main/java/com/rada/di/AppModule.kt
Normal file
64
app/src/main/java/com/rada/di/AppModule.kt
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package com.rada.di
|
||||
|
||||
import android.content.Context
|
||||
import com.rada.data.local.RadAmpDatabase
|
||||
import com.rada.data.local.dao.PlaylistDao
|
||||
import com.rada.data.local.dao.SavedSongDao
|
||||
import com.rada.data.repository.LibraryRepository
|
||||
import com.rada.data.repository.PlaylistRepository
|
||||
import com.rada.data.repository.StationRepository
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): RadAmpDatabase = RadAmpDatabase.create(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSavedSongDao(
|
||||
database: RadAmpDatabase,
|
||||
): SavedSongDao = database.savedSongDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaylistDao(
|
||||
database: RadAmpDatabase,
|
||||
): PlaylistDao = database.playlistDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStationRepository(
|
||||
@ApplicationContext context: Context,
|
||||
): StationRepository = StationRepository(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLibraryRepository(
|
||||
savedSongDao: SavedSongDao,
|
||||
): LibraryRepository = LibraryRepository(savedSongDao)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaylistRepository(
|
||||
playlistDao: PlaylistDao,
|
||||
): PlaylistRepository = PlaylistRepository(playlistDao)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackManager(
|
||||
@ApplicationContext context: Context,
|
||||
libraryRepository: LibraryRepository,
|
||||
): PlaybackManager = PlaybackManager(context, libraryRepository)
|
||||
}
|
||||
22
app/src/main/java/com/rada/domain/model/Station.kt
Normal file
22
app/src/main/java/com/rada/domain/model/Station.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.rada.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Station(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val genre: String = "",
|
||||
val description: String = "",
|
||||
val logoUrl: String = "",
|
||||
val country: String = "",
|
||||
)
|
||||
|
||||
data class NowPlaying(
|
||||
val artist: String = "",
|
||||
val title: String = "",
|
||||
val stationName: String = "",
|
||||
val isPlaying: Boolean = false,
|
||||
val station: Station? = null,
|
||||
)
|
||||
17
app/src/main/java/com/rada/domain/model/StreamEntry.kt
Normal file
17
app/src/main/java/com/rada/domain/model/StreamEntry.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package com.rada.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single playable network stream. Created either by adding a direct
|
||||
* stream URL, or by expanding a playlist (m3u / m3u8 / pls) into its entries.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamEntry(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val source: String,
|
||||
val group: String,
|
||||
val addedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
377
app/src/main/java/com/rada/player/PlaybackManager.kt
Normal file
377
app/src/main/java/com/rada/player/PlaybackManager.kt
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
package com.rada.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.rada.data.repository.LibraryRepository
|
||||
import com.rada.recording.IcyStreamReader
|
||||
import com.rada.recording.MetadataTimeline
|
||||
import com.rada.recording.RollingAudioBuffer
|
||||
import com.rada.recording.SaveSongUseCase
|
||||
import com.rada.recording.StreamMetadata
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PlaybackManager(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) {
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
private var controller: MediaController? = null
|
||||
|
||||
private var icyReader: IcyStreamReader? = null
|
||||
val metadataTimeline = MetadataTimeline()
|
||||
var audioBuffer: RollingAudioBuffer? = null
|
||||
private set
|
||||
|
||||
private lateinit var saveSongUseCase: SaveSongUseCase
|
||||
private var scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var isPlaying: Boolean = false
|
||||
private set
|
||||
|
||||
var currentStationUrl: String? = null
|
||||
private set
|
||||
|
||||
var currentStationName: String = ""
|
||||
private set
|
||||
|
||||
var bufferState: BufferState = BufferState.IDLE
|
||||
private set
|
||||
|
||||
var saveState: SaveState = SaveState.IDLE
|
||||
private set
|
||||
|
||||
var lastSaveResult: SaveSongUseCase.SaveResult? = null
|
||||
private set
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var readyAtMs = 0L
|
||||
|
||||
var listener: PlaybackListener? = null
|
||||
|
||||
private val _currentStationNameFlow = MutableStateFlow("")
|
||||
val currentStationNameFlow: StateFlow<String> = _currentStationNameFlow.asStateFlow()
|
||||
|
||||
private val _currentStationUrlFlow = MutableStateFlow<String?>(null)
|
||||
val currentStationUrlFlow: StateFlow<String?> = _currentStationUrlFlow.asStateFlow()
|
||||
|
||||
private val _isPlayingFlow = MutableStateFlow(false)
|
||||
val isPlayingFlow: StateFlow<Boolean> = _isPlayingFlow.asStateFlow()
|
||||
|
||||
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
|
||||
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
||||
|
||||
enum class BufferState {
|
||||
IDLE, INITIALIZING, BUFFERING, READY, ERROR,
|
||||
}
|
||||
|
||||
enum class SaveState {
|
||||
IDLE, SAVING, SUCCESS, ERROR,
|
||||
}
|
||||
|
||||
interface PlaybackListener {
|
||||
fun onPlaybackStateChanged(isPlaying: Boolean)
|
||||
fun onMetadataUpdate(artist: String, title: String)
|
||||
fun onError(message: String)
|
||||
fun onIcyMetadata(metadata: StreamMetadata)
|
||||
fun onBufferStateChanged(state: BufferState)
|
||||
fun onBufferBytesWritten(totalBytes: Long)
|
||||
fun onSaveStateChanged(state: SaveState, result: SaveSongUseCase.SaveResult?)
|
||||
fun onStationChanged(name: String, url: String)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
|
||||
|
||||
val sessionToken = SessionToken(
|
||||
context,
|
||||
ComponentName(context, PlaybackService::class.java)
|
||||
)
|
||||
controllerFuture = MediaController.Builder(context, sessionToken).buildAsync()
|
||||
controllerFuture?.addListener({
|
||||
controller = controllerFuture?.get()
|
||||
controller?.addListener(playerListener)
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
stopAll()
|
||||
scope.cancel()
|
||||
controller?.removeListener(playerListener)
|
||||
controller?.release()
|
||||
controller = null
|
||||
controllerFuture?.cancel(true)
|
||||
}
|
||||
|
||||
fun playStation(url: String, name: String) {
|
||||
currentStationUrl = url
|
||||
currentStationName = name
|
||||
_currentStationUrlFlow.value = url
|
||||
_currentStationNameFlow.value = name
|
||||
val ctrl = controller ?: return
|
||||
|
||||
stopAll()
|
||||
initBuffer()
|
||||
|
||||
listener?.onStationChanged(name, url)
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(name)
|
||||
.setTitle("")
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
ctrl.setMediaItem(mediaItem)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
|
||||
startIcyReader(url)
|
||||
}
|
||||
|
||||
fun playLocalFile(filePath: String, title: String, artist: String) {
|
||||
currentStationUrl = null
|
||||
currentStationName = ""
|
||||
val ctrl = controller ?: return
|
||||
|
||||
stopAll()
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(filePath)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(artist)
|
||||
.setTitle(title)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
ctrl.setMediaItem(mediaItem)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
val ctrl = controller ?: return
|
||||
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopAll()
|
||||
controller?.stop()
|
||||
isPlaying = false
|
||||
listener?.onPlaybackStateChanged(false)
|
||||
}
|
||||
|
||||
fun saveCurrentSong() {
|
||||
val buffer = audioBuffer
|
||||
if (buffer == null || bufferState != BufferState.READY) {
|
||||
listener?.onSaveStateChanged(SaveState.ERROR,
|
||||
SaveSongUseCase.SaveResult(false, error = "Buffer not ready"))
|
||||
return
|
||||
}
|
||||
|
||||
if (saveState == SaveState.SAVING) return
|
||||
|
||||
updateSaveState(SaveState.SAVING, null)
|
||||
|
||||
scope.launch {
|
||||
val result = saveSongUseCase.save(
|
||||
buffer = buffer,
|
||||
metadataTimeline = metadataTimeline,
|
||||
currentMetadata = metadataTimeline.currentMetadata,
|
||||
stationName = currentStationName,
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
lastSaveResult = result
|
||||
if (result.success) {
|
||||
updateSaveState(SaveState.SUCCESS, result)
|
||||
} else {
|
||||
updateSaveState(SaveState.ERROR, result)
|
||||
}
|
||||
|
||||
delay(3000)
|
||||
if (saveState == SaveState.SUCCESS || saveState == SaveState.ERROR) {
|
||||
updateSaveState(SaveState.IDLE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAll() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectAttempt = 0
|
||||
readyAtMs = 0L
|
||||
stopIcyReader()
|
||||
releaseBuffer()
|
||||
metadataTimeline.clear()
|
||||
updateSaveState(SaveState.IDLE, null)
|
||||
}
|
||||
|
||||
private fun initBuffer() {
|
||||
updateBufferState(BufferState.INITIALIZING)
|
||||
val buffer = RollingAudioBuffer(context)
|
||||
buffer.initialize()
|
||||
audioBuffer = buffer
|
||||
updateBufferState(BufferState.BUFFERING)
|
||||
}
|
||||
|
||||
private fun releaseBuffer() {
|
||||
audioBuffer?.release()
|
||||
audioBuffer = null
|
||||
updateBufferState(BufferState.IDLE)
|
||||
}
|
||||
|
||||
private fun startIcyReader(url: String) {
|
||||
val reader = IcyStreamReader()
|
||||
icyReader = reader
|
||||
|
||||
reader.listener = object : IcyStreamReader.Listener {
|
||||
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) {
|
||||
Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
|
||||
// Record at the reader-reported ABSOLUTE audio position (the same
|
||||
// quantity as audioBuffer.totalBytes). The previous code read
|
||||
// audioBuffer.currentWritePosition here, which is (a) a racy
|
||||
// cross-thread read and (b) a WRAPPED file position (0..max-1),
|
||||
// not an absolute one — that mismatch is what corrupted
|
||||
// boundaries after the buffer wrapped.
|
||||
metadataTimeline.record(metadata, audioBytesAtMetadata)
|
||||
listener?.onIcyMetadata(metadata)
|
||||
listener?.onMetadataUpdate(metadata.artist, metadata.title)
|
||||
}
|
||||
|
||||
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
|
||||
audioBuffer?.write(data, offset, length.toInt())
|
||||
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
|
||||
|
||||
if (bufferState == BufferState.BUFFERING || bufferState == BufferState.INITIALIZING) {
|
||||
updateBufferState(BufferState.READY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: String) {
|
||||
Log.w(TAG, "ICY reader error: $error")
|
||||
}
|
||||
|
||||
override fun onConnected(metaInterval: Int) {
|
||||
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
|
||||
}
|
||||
}
|
||||
|
||||
reader.start(url)
|
||||
}
|
||||
|
||||
private fun stopIcyReader() {
|
||||
icyReader?.stop()
|
||||
icyReader = null
|
||||
}
|
||||
|
||||
private fun updateBufferState(state: BufferState) {
|
||||
bufferState = state
|
||||
_bufferStateFlow.value = state
|
||||
listener?.onBufferStateChanged(state)
|
||||
}
|
||||
|
||||
private fun updateSaveState(state: SaveState, result: SaveSongUseCase.SaveResult?) {
|
||||
saveState = state
|
||||
listener?.onSaveStateChanged(state, result)
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
_isPlayingFlow.value = playing
|
||||
listener?.onPlaybackStateChanged(playing)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
readyAtMs = SystemClock.elapsedRealtime()
|
||||
listener?.onPlaybackStateChanged(true)
|
||||
}
|
||||
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
|
||||
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
|
||||
Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val description = describeError(error)
|
||||
updateBufferState(BufferState.ERROR)
|
||||
stopIcyReader()
|
||||
val reconnectDelayMs = scheduleReconnect()
|
||||
val message = if (reconnectDelayMs != null) {
|
||||
"$description — reconnecting in ${reconnectDelayMs / 1000}s"
|
||||
} else {
|
||||
description
|
||||
}
|
||||
listener?.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeError(error: PlaybackException): String {
|
||||
val chain = generateSequence<Throwable>(error) { it.cause }.take(10).toList()
|
||||
Log.e(
|
||||
TAG,
|
||||
"Playback error (code=${error.errorCode}): " +
|
||||
chain.joinToString(" <- ") { "${it.javaClass.simpleName}: ${it.message}" }
|
||||
)
|
||||
val root = chain.last()
|
||||
val message = root.message?.trim()
|
||||
return if (message.isNullOrEmpty()) root.javaClass.simpleName
|
||||
else "${root.javaClass.simpleName}: $message"
|
||||
}
|
||||
|
||||
private fun scheduleReconnect(): Long? {
|
||||
val url = currentStationUrl ?: return null
|
||||
if (reconnectJob?.isActive == true) return null
|
||||
|
||||
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
val delayMs = (RECONNECT_BASE_DELAY_MS shl reconnectAttempt.coerceAtMost(5))
|
||||
.coerceAtMost(RECONNECT_MAX_DELAY_MS)
|
||||
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
|
||||
|
||||
Log.d(TAG, "Auto-reconnect #$reconnectAttempt to $url in ${delayMs / 1000}s")
|
||||
|
||||
reconnectJob = scope.launch {
|
||||
delay(delayMs)
|
||||
if (currentStationUrl == url) {
|
||||
reconnectJob = null
|
||||
playStation(url, currentStationName)
|
||||
}
|
||||
}
|
||||
return delayMs
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlaybackManager"
|
||||
private const val RECONNECT_BASE_DELAY_MS = 1000L
|
||||
private const val RECONNECT_MAX_DELAY_MS = 30_000L
|
||||
private const val RECONNECT_STABLE_MS = 10_000L
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/com/rada/player/PlaybackService.kt
Normal file
56
app/src/main/java/com/rada/player/PlaybackService.kt
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package com.rada.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.rada.ui.MainActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val player = ExoPlayer.Builder(this).build()
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaSession?.player
|
||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
144
app/src/main/java/com/rada/recording/AudioExtractor.kt
Normal file
144
app/src/main/java/com/rada/recording/AudioExtractor.kt
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package com.rada.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
class AudioExtractor(private val context: Context) {
|
||||
|
||||
data class ExtractResult(
|
||||
val file: File,
|
||||
val bytesExtracted: Long,
|
||||
val isPartial: Boolean,
|
||||
)
|
||||
|
||||
fun extract(
|
||||
buffer: RollingAudioBuffer,
|
||||
boundaries: SongBoundaries,
|
||||
): ExtractResult? {
|
||||
// Bug #1: `boundaries.startByte` / `endByte` are ABSOLUTE audio byte
|
||||
// positions (the canonical coordinate). All file-offset conversion is
|
||||
// handled inside RollingAudioBuffer.readSegment — nothing is wrapped
|
||||
// here.
|
||||
val segmentBytes = boundaries.endByte - boundaries.startByte
|
||||
if (segmentBytes <= 0) {
|
||||
Log.w(TAG, "Invalid segment: start=${boundaries.startByte}, end=${boundaries.endByte}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Read segment from buffer (absolute positions in; offsets out).
|
||||
val audioData = buffer.readSegment(boundaries.startByte, boundaries.endByte)
|
||||
if (audioData == null) {
|
||||
Log.e(TAG, "Failed to read segment from buffer")
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the actual start of audio (skip any ID3 tag / pre-roll at the
|
||||
// beginning). Bug #7: this now validates the full MPEG frame header,
|
||||
// not just the sync word.
|
||||
val audioStart = findAudioStart(audioData)
|
||||
val trimmedAudio = if (audioStart > 0) {
|
||||
audioData.copyOfRange(audioStart, audioData.size)
|
||||
} else {
|
||||
audioData
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
val tempFile = File(context.cacheDir, "rada_save_${System.currentTimeMillis()}.mp3")
|
||||
try {
|
||||
tempFile.writeBytes(trimmedAudio)
|
||||
Log.d(TAG, "Extracted ${trimmedAudio.size} bytes to ${tempFile.absolutePath}")
|
||||
return ExtractResult(
|
||||
file = tempFile,
|
||||
bytesExtracted = trimmedAudio.size.toLong(),
|
||||
isPartial = boundaries.isPartial,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to write extracted audio", e)
|
||||
tempFile.delete()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the byte offset of the first valid MPEG audio frame in [data].
|
||||
*
|
||||
* Bug #7: the previous version returned the first offset whose two leading
|
||||
* bytes looked like a sync word (`0xFF` + `111xxxxx`). A radio stream is a
|
||||
* continuous byte feed, so any run of bytes containing `0xFF` followed by
|
||||
* a byte with the top three bits set would pass — producing frames that
|
||||
* start mid-sample and decode as noise. We now require the full 4-byte
|
||||
* MPEG frame header to be structurally valid (version, layer, bitrate and
|
||||
* sampling-rate indexes all in-range) before accepting a frame start.
|
||||
*/
|
||||
private fun findAudioStart(data: ByteArray): Int {
|
||||
var offset = 0
|
||||
|
||||
// Skip a leading ID3v2 tag if present.
|
||||
if (data.size >= 10 &&
|
||||
data[0] == 'I'.code.toByte() &&
|
||||
data[1] == 'D'.code.toByte() &&
|
||||
data[2] == '3'.code.toByte()
|
||||
) {
|
||||
val tagSize = ((data[6].toInt() and 0x7F) shl 21) or
|
||||
((data[7].toInt() and 0x7F) shl 14) or
|
||||
((data[8].toInt() and 0x7F) shl 7) or
|
||||
(data[9].toInt() and 0x7F)
|
||||
offset = tagSize + 10
|
||||
if (offset > data.size) offset = 0
|
||||
}
|
||||
|
||||
// Scan for a sync word whose full MPEG header validates.
|
||||
while (offset + 3 < data.size) {
|
||||
if (data[offset].toInt() and 0xFF == 0xFF &&
|
||||
data[offset + 1].toInt() and 0xE0 == 0xE0 &&
|
||||
isValidMpegFrameHeader(data, offset)
|
||||
) {
|
||||
return offset
|
||||
}
|
||||
offset++
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the four-byte MPEG frame header at [offset].
|
||||
*
|
||||
* Layout (bits are 1-indexed from the most-significant bit of each byte):
|
||||
* - byte 0: 0xFF (sync)
|
||||
* - byte 1: [7-5] sync (111) | [4-3] version | [2-1] layer | [0] protection
|
||||
* - byte 2: [7-4] bitrate index | [3-2] sampling-rate index | [1] padding | [0] private
|
||||
* - byte 3: (channel mode, emphasis, ...) — not validated here
|
||||
*
|
||||
* Rejection rules:
|
||||
* - version 00b is reserved (reject); 01 (MPEG-2), 10 (MPEG-2.5), 11 (MPEG-1) are valid.
|
||||
* - layer 11b is reserved (reject); 00 (III), 01 (II), 10 (I) are valid.
|
||||
* - bitrate index 0000b (free) and 1111b (bad) are invalid for fixed streams.
|
||||
* - sampling-rate index 11b is reserved (reject).
|
||||
*/
|
||||
private fun isValidMpegFrameHeader(data: ByteArray, offset: Int): Boolean {
|
||||
if (offset + 3 >= data.size) return false
|
||||
|
||||
val b1 = data[offset + 1].toInt() and 0xFF
|
||||
val b2 = data[offset + 2].toInt() and 0xFF
|
||||
|
||||
val version = (b1 ushr 3) and 0b11
|
||||
if (version == 0b00) return false // reserved
|
||||
|
||||
val layer = (b1 ushr 1) and 0b11
|
||||
if (layer == 0b11) return false // reserved
|
||||
|
||||
val bitrateIndex = (b2 ushr 4) and 0b1111
|
||||
if (bitrateIndex == 0b0000 || bitrateIndex == 0b1111) return false // free / bad
|
||||
|
||||
val sampleRateIndex = (b2 ushr 2) and 0b11
|
||||
if (sampleRateIndex == 0b11) return false // reserved
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AudioExtractor"
|
||||
}
|
||||
}
|
||||
216
app/src/main/java/com/rada/recording/IcyStreamReader.kt
Normal file
216
app/src/main/java/com/rada/recording/IcyStreamReader.kt
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package com.rada.recording
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class IcyStreamReader(
|
||||
private val okHttpClient: OkHttpClient = defaultClient(),
|
||||
) {
|
||||
private var scope: CoroutineScope? = null
|
||||
private var inputStream: InputStream? = null
|
||||
private var connection: okhttp3.Response? = null
|
||||
|
||||
var listener: Listener? = null
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private var metaInterval: Int = 0
|
||||
private var bytesInInterval: Int = 0
|
||||
private var pendingMetaBytes: Int = 0
|
||||
private var waitingForMetaLength: Boolean = false
|
||||
private var metadataBuffer = StringBuilder()
|
||||
private var totalBytesRead: Long = 0
|
||||
/**
|
||||
* Absolute audio byte position (audio bytes consumed since the connection
|
||||
* started, ICY metadata bytes excluded) at which the most recent metadata
|
||||
* was received.
|
||||
*
|
||||
* This is the canonical coordinate for song boundaries: it is the same
|
||||
* quantity as [RollingAudioBuffer.totalBytes]. File offsets are derived
|
||||
* from it ONLY inside [RollingAudioBuffer.readSegment].
|
||||
*/
|
||||
private var audioBytesAtMetadata: Long = 0
|
||||
|
||||
interface Listener {
|
||||
/**
|
||||
* @param metadata Parsed stream title.
|
||||
* @param audioBytesAtMetadata Absolute audio byte position (audio-only
|
||||
* bytes consumed since the connection started, ICY metadata excluded)
|
||||
* at which [metadata] was received. Matches
|
||||
* [RollingAudioBuffer.totalBytes].
|
||||
*/
|
||||
fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long)
|
||||
fun onAudioBytes(data: ByteArray, offset: Int, length: Long)
|
||||
fun onError(error: String)
|
||||
fun onConnected(metaInterval: Int)
|
||||
}
|
||||
|
||||
fun start(streamUrl: String) {
|
||||
if (isRunning) stop()
|
||||
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
scope?.launch {
|
||||
try {
|
||||
connectAndRead(streamUrl)
|
||||
} catch (e: CancellationException) {
|
||||
// Normal stop
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading stream", e)
|
||||
listener?.onError(e.message ?: "Connection error")
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
closeConnection()
|
||||
isRunning = false
|
||||
resetState()
|
||||
}
|
||||
|
||||
private fun resetState() {
|
||||
metaInterval = 0
|
||||
bytesInInterval = 0
|
||||
pendingMetaBytes = 0
|
||||
waitingForMetaLength = false
|
||||
metadataBuffer.clear()
|
||||
totalBytesRead = 0
|
||||
audioBytesAtMetadata = 0
|
||||
}
|
||||
|
||||
private fun connectAndRead(url: String) {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.header("User-Agent", "RadAmp/0.1")
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
connection = response
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
listener?.onError("HTTP ${response.code}")
|
||||
return
|
||||
}
|
||||
|
||||
val icyMetaInt = response.header("icy-metaint")
|
||||
metaInterval = icyMetaInt?.toIntOrNull() ?: 0
|
||||
|
||||
if (metaInterval > 0) {
|
||||
listener?.onConnected(metaInterval)
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
inputStream = response.body?.byteStream() ?: return
|
||||
|
||||
readStream()
|
||||
}
|
||||
|
||||
private fun readStream() {
|
||||
val stream = inputStream ?: return
|
||||
val buffer = ByteArray(8192)
|
||||
|
||||
while (isRunning) {
|
||||
val bytesRead = stream.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
|
||||
if (metaInterval > 0) {
|
||||
processBytesWithMetadata(buffer, bytesRead)
|
||||
} else {
|
||||
// No ICY metadata - all bytes are audio
|
||||
totalBytesRead += bytesRead
|
||||
listener?.onAudioBytes(buffer.copyOf(bytesRead), 0, bytesRead.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
listener?.onError("Stream ended")
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBytesWithMetadata(buffer: ByteArray, length: Int) {
|
||||
var offset = 0
|
||||
|
||||
while (offset < length && isRunning) {
|
||||
when {
|
||||
waitingForMetaLength -> {
|
||||
val metaLength = (buffer[offset].toInt() and 0xFF) * 16
|
||||
offset++
|
||||
waitingForMetaLength = false
|
||||
|
||||
if (metaLength > 0) {
|
||||
pendingMetaBytes = metaLength
|
||||
metadataBuffer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
pendingMetaBytes > 0 -> {
|
||||
val toRead = minOf(pendingMetaBytes, length - offset)
|
||||
val chunk = String(buffer, offset, toRead, Charsets.US_ASCII)
|
||||
metadataBuffer.append(chunk)
|
||||
offset += toRead
|
||||
pendingMetaBytes -= toRead
|
||||
|
||||
if (pendingMetaBytes <= 0) {
|
||||
val rawMeta = metadataBuffer.toString()
|
||||
.trim('\u0000')
|
||||
.trim()
|
||||
if (rawMeta.isNotEmpty()) {
|
||||
val metadata = StreamMetadata.parseStreamTitle(rawMeta)
|
||||
// Absolute audio position at which this metadata was
|
||||
// received: audio bytes consumed so far (metadata
|
||||
// bytes are never counted in totalBytesRead).
|
||||
audioBytesAtMetadata = totalBytesRead
|
||||
listener?.onMetadata(metadata, audioBytesAtMetadata)
|
||||
}
|
||||
metadataBuffer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Reading audio data
|
||||
val bytesUntilMeta = metaInterval - bytesInInterval
|
||||
val available = length - offset
|
||||
val toProcess = minOf(bytesUntilMeta, available)
|
||||
|
||||
if (toProcess > 0) {
|
||||
// Emit audio bytes
|
||||
listener?.onAudioBytes(buffer, offset, toProcess.toLong())
|
||||
|
||||
offset += toProcess
|
||||
bytesInInterval += toProcess
|
||||
totalBytesRead += toProcess
|
||||
}
|
||||
|
||||
if (bytesInInterval >= metaInterval) {
|
||||
bytesInInterval = 0
|
||||
waitingForMetaLength = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeConnection() {
|
||||
try { inputStream?.close() } catch (_: Exception) {}
|
||||
try { connection?.close() } catch (_: Exception) {}
|
||||
inputStream = null
|
||||
connection = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "IcyStreamReader"
|
||||
|
||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
85
app/src/main/java/com/rada/recording/MetadataEmbedder.kt
Normal file
85
app/src/main/java/com/rada/recording/MetadataEmbedder.kt
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package com.rada.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.jaudiotagger.audio.AudioFile
|
||||
import org.jaudiotagger.audio.AudioFileIO
|
||||
import org.jaudiotagger.tag.FieldKey
|
||||
import java.io.File
|
||||
|
||||
class MetadataEmbedder(private val context: Context) {
|
||||
|
||||
data class EmbedResult(
|
||||
val file: File,
|
||||
val artist: String,
|
||||
val title: String,
|
||||
val album: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bug #6: the jaudiotagger [AudioFile] reference is now stored in a local
|
||||
* and written back through that SAME reference (the previous code took
|
||||
* `.tag` off an orphaned [AudioFile] and then called
|
||||
* `AudioFileIO.write(audioFile)` passing the raw `java.io.File`, which
|
||||
* neither applies the tag mutations nor type-checks against
|
||||
* `AudioFileIO.write(AudioFile)`). The read/write now happen inside a
|
||||
* try/finally so the reference is released deterministically on every
|
||||
* path, success or failure.
|
||||
*/
|
||||
fun embed(
|
||||
audioFile: File,
|
||||
artist: String,
|
||||
title: String,
|
||||
stationName: String = "",
|
||||
outputFormat: String = "mp3",
|
||||
): EmbedResult? {
|
||||
var audio: AudioFile? = null
|
||||
return try {
|
||||
// Store the jaudiotagger AudioFile reference (Bug #6).
|
||||
audio = AudioFileIO.read(audioFile)
|
||||
|
||||
// Set metadata fields on the stored reference's tag.
|
||||
val tag = audio.tag
|
||||
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
|
||||
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
|
||||
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "RadAmp" })
|
||||
tag.setField(FieldKey.GENRE, "Radio")
|
||||
tag.setField(FieldKey.COMMENT, "Recorded from $stationName")
|
||||
|
||||
// Write back the SAME AudioFile reference holding the changes.
|
||||
AudioFileIO.write(audio)
|
||||
|
||||
Log.d(TAG, "Embedded metadata: $artist - $title")
|
||||
|
||||
EmbedResult(
|
||||
file = audioFile,
|
||||
artist = artist,
|
||||
title = title,
|
||||
album = stationName,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to embed metadata", e)
|
||||
// Return without metadata embedding - file still exists
|
||||
EmbedResult(
|
||||
file = audioFile,
|
||||
artist = artist,
|
||||
title = title,
|
||||
album = stationName,
|
||||
)
|
||||
} finally {
|
||||
// Bug #6: release the jaudiotagger AudioFile reference on every
|
||||
// path. NOTE: jaudiotagger 2.2.5's AudioFile is NOT
|
||||
// java.io.Closeable — it exposes no close() (verified against the
|
||||
// published 2.2.5/3.0.1 jars), and AudioFileIO.read()/write()
|
||||
// open and close their own RandomAccessFile per call. There is
|
||||
// therefore nothing to close(); dropping the reference here is
|
||||
// the deterministic release. If a Closeable jaudiotagger release
|
||||
// is ever adopted, call audio.close() inside this block.
|
||||
audio = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MetadataEmbedder"
|
||||
}
|
||||
}
|
||||
84
app/src/main/java/com/rada/recording/MetadataTimeline.kt
Normal file
84
app/src/main/java/com/rada/recording/MetadataTimeline.kt
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package com.rada.recording
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class MetadataTimeline {
|
||||
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val byteOffset: Long,
|
||||
val metadata: StreamMetadata,
|
||||
)
|
||||
|
||||
/**
|
||||
* [CopyOnWriteArrayList] rather than a synchronized block because entries
|
||||
* are written once per song (low frequency) and read on every save and by
|
||||
* the detector — copy-on-write keeps reads lock-free and allocation-cheap
|
||||
* at this write rate.
|
||||
*/
|
||||
private val entries = CopyOnWriteArrayList<Entry>()
|
||||
@Volatile
|
||||
private var lastMetadata: StreamMetadata? = null
|
||||
@Volatile
|
||||
private var lastByteOffset: Long = 0
|
||||
|
||||
val currentMetadata: StreamMetadata?
|
||||
get() = lastMetadata
|
||||
|
||||
val allEntries: List<Entry>
|
||||
get() = entries.toList()
|
||||
|
||||
/**
|
||||
* @param metadata Parsed stream title.
|
||||
* @param byteOffset ABSOLUTE audio byte position (audio bytes consumed
|
||||
* since the stream connection started — matches
|
||||
* [RollingAudioBuffer.totalBytes]). Never a wrapped file offset.
|
||||
*/
|
||||
fun record(metadata: StreamMetadata, byteOffset: Long) {
|
||||
// Only record if the artist/title actually changed. (The previous
|
||||
// condition also demanded BOTH fields be empty, so identical
|
||||
// re-broadcasts of a non-empty title were recorded as new entries,
|
||||
// and a genuine title change was only filtered when the new title
|
||||
// happened to be empty.)
|
||||
val prev = lastMetadata
|
||||
if (prev != null &&
|
||||
prev.artist == metadata.artist &&
|
||||
prev.title == metadata.title
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val entry = Entry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
byteOffset = byteOffset,
|
||||
metadata = metadata.copy(byteOffset = byteOffset),
|
||||
)
|
||||
entries.add(entry)
|
||||
lastMetadata = metadata
|
||||
lastByteOffset = byteOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* The second-to-last entry marks the start of the current song; the last
|
||||
* entry is the metadata change that produced the song currently playing.
|
||||
* With fewer than two entries the only entry is the best we have.
|
||||
*/
|
||||
fun findSongStart(): Entry? {
|
||||
if (entries.size < 2) return entries.lastOrNull()
|
||||
// The second-to-last entry marks the start of the current song
|
||||
return entries.getOrNull(entries.size - 2)
|
||||
}
|
||||
|
||||
fun findCurrentSongEntries(): List<Entry> {
|
||||
if (entries.isEmpty()) return emptyList()
|
||||
// All entries from the second-to-last (song start) to the end
|
||||
val startIdx = maxOf(0, entries.size - 2)
|
||||
return entries.subList(startIdx, entries.size)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
lastMetadata = null
|
||||
lastByteOffset = 0
|
||||
}
|
||||
}
|
||||
183
app/src/main/java/com/rada/recording/RollingAudioBuffer.kt
Normal file
183
app/src/main/java/com/rada/recording/RollingAudioBuffer.kt
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package com.rada.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
class RollingAudioBuffer(
|
||||
context: Context,
|
||||
private val maxBytes: Long = DEFAULT_MAX_BYTES,
|
||||
) {
|
||||
private val bufferFile: File = File(context.cacheDir, "rada_buffer_${System.currentTimeMillis()}.tmp")
|
||||
private var raf: RandomAccessFile? = null
|
||||
|
||||
@Volatile
|
||||
private var writePos: Long = 0
|
||||
|
||||
@Volatile
|
||||
private var totalBytesWritten: Long = 0
|
||||
|
||||
@Volatile
|
||||
private var hasWrapped: Boolean = false
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
/**
|
||||
* ABSOLUTE audio byte position of the next byte to be written: total audio
|
||||
* bytes consumed since the buffer was created (never wraps, keeps growing).
|
||||
* This is the canonical coordinate used by [IcyStreamReader],
|
||||
* [MetadataTimeline] and [SongBoundaryDetector].
|
||||
*/
|
||||
val currentWritePosition: Long get() = totalBytesWritten
|
||||
|
||||
/** Total audio bytes written so far (same coordinate as [currentWritePosition]). */
|
||||
val totalBytes: Long get() = totalBytesWritten
|
||||
|
||||
val isWrapped: Boolean get() = hasWrapped
|
||||
val bytesAvailable: Long
|
||||
get() = if (hasWrapped) maxBytes else totalBytesWritten
|
||||
|
||||
/** Fixed capacity of the underlying file in bytes (never grows). */
|
||||
val maxCapacity: Long get() = maxBytes
|
||||
|
||||
val bufferFileAbsolutePath: String get() = bufferFile.absolutePath
|
||||
|
||||
fun initialize() {
|
||||
if (isInitialized) return
|
||||
try {
|
||||
raf = RandomAccessFile(bufferFile, "rw")
|
||||
raf?.setLength(maxBytes)
|
||||
isInitialized = true
|
||||
Log.d(TAG, "Buffer initialized: ${bufferFile.absolutePath} ($maxBytes bytes)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize buffer", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun write(data: ByteArray, offset: Int, length: Int) {
|
||||
val file = raf ?: return
|
||||
if (!isInitialized) return
|
||||
|
||||
try {
|
||||
var remaining = length
|
||||
var srcOffset = offset
|
||||
|
||||
while (remaining > 0) {
|
||||
val writeChunk = minOf(remaining.toLong(), maxBytes - writePos).toInt()
|
||||
|
||||
file.seek(writePos)
|
||||
file.write(data, srcOffset, writeChunk)
|
||||
|
||||
writePos += writeChunk
|
||||
totalBytesWritten += writeChunk
|
||||
srcOffset += writeChunk
|
||||
remaining -= writeChunk
|
||||
|
||||
// Wrap around
|
||||
if (writePos >= maxBytes) {
|
||||
writePos = 0
|
||||
hasWrapped = true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Write error", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a segment of buffered audio.
|
||||
*
|
||||
* [startByte] and [endByte] are ABSOLUTE audio byte positions in the
|
||||
* canonical coordinate (total audio bytes since the buffer was created —
|
||||
* the same space as [currentWritePosition]/[totalBytes]). The conversion
|
||||
* to file offsets happens ONLY here, inside this method: every caller
|
||||
* passes absolute positions and never touches file offsets.
|
||||
*
|
||||
* Valid range: [max(0, totalBytesWritten - maxBytes), totalBytesWritten].
|
||||
* Returns null if the range is invalid or (before the first wrap) extends
|
||||
* beyond the data written so far.
|
||||
*/
|
||||
fun readSegment(startByte: Long, endByte: Long): ByteArray? {
|
||||
val file = raf ?: return null
|
||||
if (!isInitialized) return null
|
||||
if (startByte < 0 || endByte <= startByte) return null
|
||||
|
||||
// Before the first wrap, data only exists in [0, writePos); reject
|
||||
// reads that reach into not-yet-written file space (previously this
|
||||
// check was missing in the wrapped path and broken in the plain path).
|
||||
if (!hasWrapped && (startByte >= totalBytesWritten || endByte > totalBytesWritten)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val length = (endByte - startByte).toInt()
|
||||
if (length <= 0 || length > maxBytes) return null
|
||||
|
||||
// Absolute position -> file offset. This is the only place in the app
|
||||
// where file offsets are computed.
|
||||
val startOffset = startByte % maxBytes
|
||||
val endOffset = endByte % maxBytes
|
||||
|
||||
return try {
|
||||
val result = ByteArray(length)
|
||||
if (endOffset > startOffset) {
|
||||
// Segment does not cross the end of the file
|
||||
file.seek(startOffset)
|
||||
file.readFully(result)
|
||||
} else {
|
||||
// Segment crosses the wrap boundary: read to end of file,
|
||||
// then continue from the beginning.
|
||||
val firstPart = (maxBytes - startOffset).toInt()
|
||||
file.seek(startOffset)
|
||||
file.readFully(result, 0, firstPart)
|
||||
file.seek(0)
|
||||
file.readFully(result, firstPart, length - firstPart)
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Read error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getWritePositionForByte(targetByte: Long): Long {
|
||||
return if (hasWrapped) {
|
||||
targetByte % maxBytes
|
||||
} else {
|
||||
targetByte
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
writePos = 0
|
||||
totalBytesWritten = 0
|
||||
hasWrapped = false
|
||||
}
|
||||
|
||||
fun release() {
|
||||
try {
|
||||
raf?.close()
|
||||
} catch (_: Exception) {}
|
||||
raf = null
|
||||
isInitialized = false
|
||||
|
||||
try {
|
||||
if (bufferFile.exists()) {
|
||||
bufferFile.delete()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RollingAudioBuffer"
|
||||
|
||||
// Default: 5 minutes at 128kbps = 4.8 MB
|
||||
// 128 kbps = 16,000 bytes/sec × 300 sec = 4,800,000 bytes
|
||||
const val DEFAULT_MAX_BYTES = 5_000_000L
|
||||
|
||||
fun createForDuration(context: Context, durationSeconds: Int, bitrateBytesPerSec: Int = 16000): RollingAudioBuffer {
|
||||
val maxBytes = durationSeconds.toLong() * bitrateBytesPerSec
|
||||
return RollingAudioBuffer(context, maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/src/main/java/com/rada/recording/SaveSongUseCase.kt
Normal file
150
app/src/main/java/com/rada/recording/SaveSongUseCase.kt
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package com.rada.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import com.rada.data.repository.LibraryRepository
|
||||
import java.io.File
|
||||
|
||||
class SaveSongUseCase(
|
||||
private val context: Context,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) {
|
||||
private val boundaryDetector = SongBoundaryDetector()
|
||||
private val audioExtractor = AudioExtractor(context)
|
||||
private val metadataEmbedder = MetadataEmbedder(context)
|
||||
|
||||
data class SaveResult(
|
||||
val success: Boolean,
|
||||
val filePath: String? = null,
|
||||
val fileName: String? = null,
|
||||
val songId: Long? = null,
|
||||
val isPartial: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
suspend fun save(
|
||||
buffer: RollingAudioBuffer,
|
||||
metadataTimeline: MetadataTimeline,
|
||||
currentMetadata: StreamMetadata?,
|
||||
stationName: String,
|
||||
): SaveResult {
|
||||
// 1. Detect song boundaries
|
||||
val boundaries = boundaryDetector.detectBoundaries(
|
||||
timeline = metadataTimeline,
|
||||
currentPositionBytes = buffer.currentWritePosition,
|
||||
currentMetadata = currentMetadata,
|
||||
bufferTotalBytes = buffer.totalBytes,
|
||||
bufferMaxBytes = buffer.maxCapacity,
|
||||
bufferHasWrapped = buffer.isWrapped,
|
||||
)
|
||||
|
||||
if (boundaries == null) {
|
||||
return SaveResult(
|
||||
success = false,
|
||||
error = "No metadata available. Cannot determine song boundaries.",
|
||||
)
|
||||
}
|
||||
|
||||
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
|
||||
val minBytes = 160_000L
|
||||
if (boundaries.durationBytes < minBytes) {
|
||||
return SaveResult(
|
||||
success = false,
|
||||
error = "Not enough audio buffered. Wait for the song to play longer.",
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Extract audio segment (returns null on failure)
|
||||
val extractResult = audioExtractor.extract(buffer, boundaries)
|
||||
if (extractResult == null) {
|
||||
return SaveResult(
|
||||
success = false,
|
||||
error = "Failed to extract audio from buffer.",
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the extracted file alive until the end of the save; it is
|
||||
// deleted in the finally block regardless of success/failure so a
|
||||
// thrown exception can never leak a temp file.
|
||||
val extractedFile = extractResult.file
|
||||
return try {
|
||||
// 3. Embed metadata (never fails hard — returns the file as-is)
|
||||
metadataEmbedder.embed(
|
||||
audioFile = extractedFile,
|
||||
artist = boundaries.artist,
|
||||
title = boundaries.title,
|
||||
stationName = stationName,
|
||||
)
|
||||
|
||||
// 4. Move to permanent storage
|
||||
val outputDir = getSaveDirectory()
|
||||
if (!outputDir.exists()) {
|
||||
outputDir.mkdirs()
|
||||
}
|
||||
|
||||
val outputFile = File(outputDir, "${boundaries.fileName}.mp3")
|
||||
|
||||
// Handle duplicate names
|
||||
val finalFile = if (outputFile.exists()) {
|
||||
var counter = 1
|
||||
var candidate = File(outputDir, "${boundaries.fileName} ($counter).mp3")
|
||||
while (candidate.exists()) {
|
||||
counter++
|
||||
candidate = File(outputDir, "${boundaries.fileName} ($counter).mp3")
|
||||
}
|
||||
candidate
|
||||
} else {
|
||||
outputFile
|
||||
}
|
||||
|
||||
extractedFile.copyTo(finalFile, overwrite = true)
|
||||
extractedFile.delete()
|
||||
|
||||
Log.d(TAG, "Saved song: ${finalFile.absolutePath}")
|
||||
|
||||
// 5. Insert into library database
|
||||
val entity = SavedSongEntity(
|
||||
fileName = finalFile.name,
|
||||
filePath = finalFile.absolutePath,
|
||||
artist = boundaries.artist.ifEmpty { stationName },
|
||||
title = boundaries.title.ifEmpty { "Unknown Title" },
|
||||
album = stationName,
|
||||
stationName = stationName,
|
||||
fileSize = finalFile.length(),
|
||||
format = "mp3",
|
||||
)
|
||||
val songId = libraryRepository.insert(entity)
|
||||
|
||||
SaveResult(
|
||||
success = true,
|
||||
filePath = finalFile.absolutePath,
|
||||
fileName = finalFile.name,
|
||||
songId = songId,
|
||||
isPartial = boundaries.isPartial,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save file", e)
|
||||
SaveResult(
|
||||
success = false,
|
||||
error = "Failed to save file: ${e.message}",
|
||||
)
|
||||
} finally {
|
||||
// Guarantee: the temp file is always removed, even if embedding,
|
||||
// the copy, or the DB insert threw.
|
||||
if (extractedFile.exists()) {
|
||||
extractedFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSaveDirectory(): File {
|
||||
val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)
|
||||
return File(musicDir, "RadAmp")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SaveSongUseCase"
|
||||
}
|
||||
}
|
||||
140
app/src/main/java/com/rada/recording/SongBoundaryDetector.kt
Normal file
140
app/src/main/java/com/rada/recording/SongBoundaryDetector.kt
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package com.rada.recording
|
||||
|
||||
data class SongBoundaries(
|
||||
val startByte: Long,
|
||||
val endByte: Long,
|
||||
val artist: String,
|
||||
val title: String,
|
||||
val stationName: String,
|
||||
val isPartial: Boolean = false,
|
||||
) {
|
||||
val durationBytes: Long get() = endByte - startByte
|
||||
val fileName: String
|
||||
get() {
|
||||
val name = if (artist.isNotEmpty() && title.isNotEmpty()) {
|
||||
"$artist - $title"
|
||||
} else if (title.isNotEmpty()) {
|
||||
title
|
||||
} else if (artist.isNotEmpty()) {
|
||||
artist
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
return sanitizeFileName(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ILLEGAL_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
|
||||
|
||||
fun sanitizeFileName(name: String): String {
|
||||
return name.map { c ->
|
||||
if (c in ILLEGAL_CHARS || c.code < 32) '_' else c
|
||||
}.joinToString("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SongBoundaryDetector {
|
||||
|
||||
/**
|
||||
* Bytes shaved off the END of a detected segment. ICY StreamTitle is
|
||||
* broadcast with up to ~1 second of delay after the song actually starts,
|
||||
* and the stream may contain pre-roll between songs, so a hard cut at
|
||||
* the metadata byte offset can land in the tail of the PREVIOUS song.
|
||||
* Trimming a small safety margin keeps the cut inside the current song.
|
||||
*/
|
||||
private val END_SAFETY_MARGIN_BYTES = 8_000L // ~0.5s at 128 kbps
|
||||
|
||||
fun detectBoundaries(
|
||||
timeline: MetadataTimeline,
|
||||
currentPositionBytes: Long,
|
||||
currentMetadata: StreamMetadata?,
|
||||
bufferTotalBytes: Long,
|
||||
bufferMaxBytes: Long,
|
||||
bufferHasWrapped: Boolean,
|
||||
): SongBoundaries? {
|
||||
val entries = timeline.allEntries
|
||||
if (entries.isEmpty()) return null
|
||||
|
||||
// Song start: the second-to-last timeline entry marks the start of the
|
||||
// CURRENT song — the last entry is the change that brought us HERE
|
||||
// (i.e. the previous song's tail). Using the last entry would make the
|
||||
// segment cover only the gap since the previous song's metadata, which
|
||||
// is wrong whenever that gap is short.
|
||||
val startEntry = timeline.findSongStart() ?: return null
|
||||
val artist = currentMetadata?.artist?.ifEmpty { startEntry.metadata.artist }
|
||||
?: startEntry.metadata.artist
|
||||
val title = currentMetadata?.title?.ifEmpty { startEntry.metadata.title }
|
||||
?: startEntry.metadata.title
|
||||
val stationName = "" // Will be filled by caller
|
||||
|
||||
val rawStartByte = startEntry.byteOffset
|
||||
val endByte = currentPositionBytes
|
||||
|
||||
// Safety margin: never cut closer than the margin to the write head,
|
||||
// and never start before byte 0.
|
||||
val startByte = rawStartByte.coerceAtLeast(0L)
|
||||
val safeEndByte = (endByte - END_SAFETY_MARGIN_BYTES).coerceAtLeast(startByte)
|
||||
|
||||
// Check if we have enough buffer for this segment
|
||||
val segmentSize = safeEndByte - startByte
|
||||
val availableBytes = if (bufferHasWrapped) bufferMaxBytes else bufferTotalBytes
|
||||
val isPartial = segmentSize > availableBytes
|
||||
|
||||
return SongBoundaries(
|
||||
startByte = if (isPartial) {
|
||||
// Buffer doesn't cover the full song - start from earliest available
|
||||
maxOf(startByte, safeEndByte - availableBytes)
|
||||
} else {
|
||||
startByte
|
||||
},
|
||||
endByte = safeEndByte,
|
||||
artist = artist,
|
||||
title = title,
|
||||
stationName = stationName,
|
||||
isPartial = isPartial,
|
||||
)
|
||||
}
|
||||
|
||||
fun detectBoundariesFromEntries(
|
||||
entries: List<MetadataTimeline.Entry>,
|
||||
currentPositionBytes: Long,
|
||||
currentMetadata: StreamMetadata?,
|
||||
bufferTotalBytes: Long,
|
||||
bufferMaxBytes: Long,
|
||||
bufferHasWrapped: Boolean,
|
||||
): SongBoundaries? {
|
||||
if (entries.isEmpty()) return null
|
||||
|
||||
// Same rule as detectBoundaries: second-to-last entry = current song start.
|
||||
val startEntry = entries.getOrNull((entries.size - 2).coerceAtLeast(0))
|
||||
?: return null
|
||||
val artist = currentMetadata?.artist?.ifEmpty { startEntry.metadata.artist }
|
||||
?: startEntry.metadata.artist
|
||||
val title = currentMetadata?.title?.ifEmpty { startEntry.metadata.title }
|
||||
?: startEntry.metadata.title
|
||||
|
||||
val rawStartByte = startEntry.byteOffset
|
||||
val endByte = currentPositionBytes
|
||||
|
||||
val startByte = rawStartByte.coerceAtLeast(0L)
|
||||
val safeEndByte = (endByte - END_SAFETY_MARGIN_BYTES).coerceAtLeast(startByte)
|
||||
|
||||
val segmentSize = safeEndByte - startByte
|
||||
val availableBytes = if (bufferHasWrapped) bufferMaxBytes else bufferTotalBytes
|
||||
val isPartial = segmentSize > availableBytes
|
||||
|
||||
return SongBoundaries(
|
||||
startByte = if (isPartial) {
|
||||
maxOf(startByte, safeEndByte - availableBytes)
|
||||
} else {
|
||||
startByte
|
||||
},
|
||||
endByte = safeEndByte,
|
||||
artist = artist,
|
||||
title = title,
|
||||
stationName = "",
|
||||
isPartial = isPartial,
|
||||
)
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/rada/recording/StreamMetadata.kt
Normal file
42
app/src/main/java/com/rada/recording/StreamMetadata.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package com.rada.recording
|
||||
|
||||
data class StreamMetadata(
|
||||
val artist: String = "",
|
||||
val title: String = "",
|
||||
val raw: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val byteOffset: Long = 0,
|
||||
) {
|
||||
val displayArtist: String
|
||||
get() = artist.ifEmpty { title }
|
||||
|
||||
val displayTitle: String
|
||||
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
|
||||
|
||||
companion object {
|
||||
fun parseStreamTitle(raw: String): StreamMetadata {
|
||||
val cleaned = raw.trim().removeSuffix(";")
|
||||
var artist = ""
|
||||
var title = ""
|
||||
|
||||
// Parse StreamTitle='Artist - Title'
|
||||
val titleMatch = Regex("""StreamTitle='([^']*)'""").find(cleaned)
|
||||
if (titleMatch != null) {
|
||||
val value = titleMatch.groupValues[1].trim()
|
||||
if (value.contains(" - ")) {
|
||||
val parts = value.split(" - ", limit = 2)
|
||||
artist = parts[0].trim()
|
||||
title = parts[1].trim()
|
||||
} else {
|
||||
title = value
|
||||
}
|
||||
}
|
||||
|
||||
return StreamMetadata(
|
||||
artist = artist,
|
||||
title = title,
|
||||
raw = raw,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/rada/ui/MainActivity.kt
Normal file
44
app/src/main/java/com/rada/ui/MainActivity.kt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package com.rada.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.rada.player.PlaybackManager
|
||||
import com.rada.ui.navigation.NavGraph
|
||||
import com.rada.ui.theme.RadAmpTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var playbackManager: PlaybackManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
playbackManager.connect()
|
||||
|
||||
setContent {
|
||||
RadAmpTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
NavGraph(navController = rememberNavController())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
playbackManager.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
343
app/src/main/java/com/rada/ui/library/LibraryScreen.kt
Normal file
343
app/src/main/java/com/rada/ui/library/LibraryScreen.kt
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package com.rada.ui.library
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import com.rada.data.repository.SortOrder
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumPurpleLight
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumSurfaceVariant
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
onBack: () -> Unit,
|
||||
initialPlaylistId: Long? = null,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val filteredPlaylists by viewModel.filteredPlaylists.collectAsState()
|
||||
val allPlaylists by viewModel.allPlaylists.collectAsState()
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(initialPlaylistId) {
|
||||
if (initialPlaylistId != null) {
|
||||
viewModel.selectPlaylist(initialPlaylistId)
|
||||
}
|
||||
}
|
||||
|
||||
val allTabLabel = "All Songs"
|
||||
val tabLabels = listOf(allTabLabel) + filteredPlaylists.map { it.name }
|
||||
val selectedTabIndex = remember(uiState.selectedPlaylistId, filteredPlaylists) {
|
||||
if (uiState.selectedPlaylistId == null) {
|
||||
0
|
||||
} else {
|
||||
val idx = filteredPlaylists.indexOfFirst { it.id == uiState.selectedPlaylistId }
|
||||
if (idx >= 0) idx + 1 else 0
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Library") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (uiState.selectedPlaylistId != null && uiState.songs.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.shufflePlay() }) {
|
||||
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showSortMenu = !showSortMenu }) {
|
||||
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (tabLabels.size > 1) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
edgePadding = 16.dp,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = PremiumPurple,
|
||||
indicator = { tabPositions ->
|
||||
if (selectedTabIndex < tabPositions.size) {
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
|
||||
color = PremiumPurple,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
tabLabels.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = {
|
||||
if (index == 0) {
|
||||
viewModel.selectPlaylist(null)
|
||||
} else {
|
||||
viewModel.selectPlaylist(filteredPlaylists[index - 1].id)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (selectedTabIndex == index) PremiumPurple else PremiumTextSecondary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = searchQuery,
|
||||
onValueChange = {
|
||||
searchQuery = it
|
||||
viewModel.search(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search songs & playlists...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
tint = PremiumTextSecondary,
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = PremiumSurface,
|
||||
unfocusedContainerColor = PremiumSurface,
|
||||
focusedIndicatorColor = PremiumPurple.copy(alpha = 0.5f),
|
||||
unfocusedIndicatorColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = showSortMenu) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SortChip("Date", uiState.songs.isNotEmpty()) {
|
||||
viewModel.setSortOrder(SortOrder.DATE_SAVED)
|
||||
showSortMenu = false
|
||||
}
|
||||
SortChip("Artist", uiState.songs.isNotEmpty()) {
|
||||
viewModel.setSortOrder(SortOrder.ARTIST)
|
||||
showSortMenu = false
|
||||
}
|
||||
SortChip("Title", uiState.songs.isNotEmpty()) {
|
||||
viewModel.setSortOrder(SortOrder.TITLE)
|
||||
showSortMenu = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.songs.isEmpty() && !uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = PremiumPurple.copy(alpha = 0.3f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = if (uiState.selectedPlaylistId != null) "Playlist is empty" else "No saved songs yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(uiState.songs, key = { it.id }) { song ->
|
||||
SongListItem(
|
||||
song = song,
|
||||
isPlaying = song.id == uiState.currentPlayingId,
|
||||
onClick = { viewModel.playSong(song) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
|
||||
onDelete = { viewModel.deleteSong(song) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortChip(
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = PremiumSurface,
|
||||
labelColor = PremiumTextSecondary,
|
||||
disabledContainerColor = PremiumSurfaceVariant,
|
||||
disabledLabelColor = PremiumTextSecondary.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SongListItem(
|
||||
song: SavedSongEntity,
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = if (isPlaying) PremiumPurple else PremiumTextSecondary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = song.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = PremiumText,
|
||||
)
|
||||
Text(
|
||||
text = song.artist,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (song.stationName.isNotEmpty()) {
|
||||
Text(
|
||||
text = song.stationName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = PremiumTextSecondary.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onToggleFavorite) {
|
||||
Icon(
|
||||
if (song.isFavorite) Icons.Default.Favorite
|
||||
else Icons.Default.FavoriteBorder,
|
||||
contentDescription = if (song.isFavorite) "Unfavorite" else "Favorite",
|
||||
tint = if (song.isFavorite) PremiumPurple else PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
178
app/src/main/java/com/rada/ui/library/LibraryViewModel.kt
Normal file
178
app/src/main/java/com/rada/ui/library/LibraryViewModel.kt
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package com.rada.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.local.entity.SavedSongEntity
|
||||
import com.rada.data.repository.LibraryRepository
|
||||
import com.rada.data.repository.PlaylistRepository
|
||||
import com.rada.data.repository.SortOrder
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LibraryUiState())
|
||||
val uiState: StateFlow<LibraryUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
|
||||
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
|
||||
|
||||
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val filteredPlaylists: StateFlow<List<PlaylistEntity>> = combine(
|
||||
allPlaylists, searchQuery
|
||||
) { playlists, query ->
|
||||
if (query.isBlank()) playlists
|
||||
else playlists.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
init {
|
||||
// flatMapLatest cancels the previous songs flow when the selection
|
||||
// changes, so picking a playlist immediately switches to the
|
||||
// playlist-filtered query instead of staying on the full library.
|
||||
val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId ->
|
||||
Triple(query, sort, playlistId)
|
||||
}.flatMapLatest { (query, sort, playlistId) ->
|
||||
when {
|
||||
// A playlist selection always wins: query strictly by playlist_id.
|
||||
playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId)
|
||||
query.isEmpty() -> libraryRepository.getAll(sort)
|
||||
else -> libraryRepository.search(query)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
songsFlow.collect { songs ->
|
||||
_uiState.update { it.copy(songs = songs, isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playSong(song: SavedSongEntity) {
|
||||
playbackManager.playLocalFile(
|
||||
filePath = song.filePath,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
)
|
||||
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
|
||||
}
|
||||
|
||||
fun shufflePlay() {
|
||||
val songs = _uiState.value.songs
|
||||
if (songs.isEmpty()) return
|
||||
val shuffled = songs.shuffled(Random)
|
||||
_uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) }
|
||||
val first = shuffled.first()
|
||||
playbackManager.playLocalFile(
|
||||
filePath = first.filePath,
|
||||
title = first.title,
|
||||
artist = first.artist,
|
||||
)
|
||||
_uiState.update { it.copy(currentPlayingId = first.id) }
|
||||
}
|
||||
|
||||
fun playNextInShuffle(): Boolean {
|
||||
val queue = _uiState.value.shuffleQueue
|
||||
val idx = _uiState.value.shuffleIndex
|
||||
if (queue.isEmpty() || idx < 0) return false
|
||||
val nextIdx = idx + 1
|
||||
if (nextIdx >= queue.size) return false
|
||||
val next = queue[nextIdx]
|
||||
playbackManager.playLocalFile(
|
||||
filePath = next.filePath,
|
||||
title = next.title,
|
||||
artist = next.artist,
|
||||
)
|
||||
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun playPreviousInShuffle(): Boolean {
|
||||
val queue = _uiState.value.shuffleQueue
|
||||
val idx = _uiState.value.shuffleIndex
|
||||
if (queue.isEmpty() || idx <= 0) return false
|
||||
val prevIdx = idx - 1
|
||||
val prev = queue[prevIdx]
|
||||
playbackManager.playLocalFile(
|
||||
filePath = prev.filePath,
|
||||
title = prev.title,
|
||||
artist = prev.artist,
|
||||
)
|
||||
_uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun toggleFavorite(songId: Long) {
|
||||
viewModelScope.launch {
|
||||
libraryRepository.toggleFavorite(songId)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSong(song: SavedSongEntity) {
|
||||
viewModelScope.launch {
|
||||
libraryRepository.delete(song)
|
||||
try {
|
||||
val file = java.io.File(song.filePath)
|
||||
if (file.exists()) file.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
fun setSortOrder(order: SortOrder) {
|
||||
sortOrder.value = order
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
searchQuery.value = ""
|
||||
}
|
||||
|
||||
fun selectPlaylist(playlistId: Long?) {
|
||||
selectedPlaylistId.value = playlistId
|
||||
_uiState.update { it.copy(selectedPlaylistId = playlistId) }
|
||||
}
|
||||
|
||||
fun deletePlaylist(playlist: PlaylistEntity) {
|
||||
viewModelScope.launch {
|
||||
playlistRepository.deletePlaylist(playlist)
|
||||
if (selectedPlaylistId.value == playlist.id) {
|
||||
selectedPlaylistId.value = null
|
||||
_uiState.update { it.copy(selectedPlaylistId = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LibraryUiState(
|
||||
val songs: List<SavedSongEntity> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val currentPlayingId: Long? = null,
|
||||
val selectedPlaylistId: Long? = null,
|
||||
val shuffleQueue: List<SavedSongEntity> = emptyList(),
|
||||
val shuffleIndex: Int = -1,
|
||||
) {
|
||||
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
|
||||
}
|
||||
89
app/src/main/java/com/rada/ui/navigation/NavGraph.kt
Normal file
89
app/src/main/java/com/rada/ui/navigation/NavGraph.kt
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package com.rada.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.rada.ui.library.LibraryScreen
|
||||
import com.rada.ui.player.NowPlayingBar
|
||||
import com.rada.ui.player.PlayerScreen
|
||||
import com.rada.ui.playlists.PlaylistManagerScreen
|
||||
import com.rada.ui.stations.StationBrowserScreen
|
||||
import com.rada.ui.streams.StreamsScreen
|
||||
|
||||
@Composable
|
||||
fun NavGraph(navController: NavHostController) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val showNowPlayingBar = currentRoute != Screen.Player.route
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Player.route,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
composable(Screen.Player.route) {
|
||||
PlayerScreen(
|
||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
||||
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
|
||||
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.Library.route,
|
||||
arguments = listOf(
|
||||
navArgument("playlistId") {
|
||||
type = NavType.LongType
|
||||
defaultValue = -1L
|
||||
}
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
|
||||
LibraryScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
initialPlaylistId = playlistId,
|
||||
)
|
||||
}
|
||||
composable(Screen.Stations.route) {
|
||||
StationBrowserScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onStationSelected = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Streams.route) {
|
||||
StreamsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onStreamSelected = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Playlists.route) {
|
||||
PlaylistManagerScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
navController.navigate(Screen.Library.createRoute(playlistId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showNowPlayingBar) {
|
||||
NowPlayingBar(
|
||||
onNavigateToPlayer = {
|
||||
navController.navigate(Screen.Player.route) {
|
||||
popUpTo(Screen.Player.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/rada/ui/navigation/Screen.kt
Normal file
13
app/src/main/java/com/rada/ui/navigation/Screen.kt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package com.rada.ui.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Player : Screen("player")
|
||||
data object Library : Screen("library?playlistId={playlistId}") {
|
||||
fun createRoute(playlistId: Long? = null): String {
|
||||
return if (playlistId != null) "library?playlistId=$playlistId" else "library"
|
||||
}
|
||||
}
|
||||
data object Stations : Screen("stations")
|
||||
data object Streams : Screen("streams")
|
||||
data object Playlists : Screen("playlists")
|
||||
}
|
||||
128
app/src/main/java/com/rada/ui/player/NowPlayingBar.kt
Normal file
128
app/src/main/java/com/rada/ui/player/NowPlayingBar.kt
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package com.rada.ui.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.player.PlaybackManager
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
|
||||
@Composable
|
||||
fun NowPlayingBar(
|
||||
onNavigateToPlayer: () -> Unit,
|
||||
viewModel: NowPlayingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val stationName by viewModel.currentStationName.collectAsState()
|
||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||
val bufferState by viewModel.bufferState.collectAsState()
|
||||
val isVisible = stationName.isNotEmpty()
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PremiumSurface)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onNavigateToPlayer,
|
||||
),
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { when (bufferState) {
|
||||
PlaybackManager.BufferState.READY -> 1f
|
||||
PlaybackManager.BufferState.BUFFERING -> 0.5f
|
||||
PlaybackManager.BufferState.INITIALIZING -> 0.3f
|
||||
else -> 0f
|
||||
}},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = PremiumPurple,
|
||||
trackColor = PremiumSurface,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = PremiumPurple,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onNavigateToPlayer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stationName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = PremiumText,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.togglePlayPause() },
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = PremiumPurple,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt
Normal file
22
app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.rada.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NowPlayingViewModel @Inject constructor(
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val currentStationName: StateFlow<String> = playbackManager.currentStationNameFlow
|
||||
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
||||
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
||||
|
||||
fun togglePlayPause() {
|
||||
playbackManager.togglePlayPause()
|
||||
}
|
||||
}
|
||||
801
app/src/main/java/com/rada/ui/player/PlayerScreen.kt
Normal file
801
app/src/main/java/com/rada/ui/player/PlayerScreen.kt
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
package com.rada.ui.player
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.Podcasts
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.player.PlaybackManager
|
||||
import com.rada.ui.theme.GradientEnd
|
||||
import com.rada.ui.theme.GradientMid
|
||||
import com.rada.ui.theme.GradientStart
|
||||
import com.rada.ui.theme.PremiumCyan
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumPurpleLight
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumSurfaceVariant
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
onNavigateToStations: () -> Unit,
|
||||
onNavigateToLibrary: () -> Unit,
|
||||
onNavigateToStreams: () -> Unit,
|
||||
onNavigateToPlaylists: () -> Unit,
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val playlists by viewModel.playlists.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Sync state when screen becomes visible (e.g. returning from Stations)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.syncPlaybackState()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let { error ->
|
||||
snackbarHostState.showSnackbar(error)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.playlistPickerMessage) {
|
||||
uiState.playlistPickerMessage?.let { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
viewModel.clearPlaylistPickerMessage()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.saveState) {
|
||||
val result = uiState.lastSaveResult
|
||||
if (result != null && uiState.saveState == PlaybackManager.SaveState.SUCCESS) {
|
||||
viewModel.clearSaveResult()
|
||||
} else if (result != null && uiState.saveState == PlaybackManager.SaveState.ERROR) {
|
||||
snackbarHostState.showSnackbar(result.error ?: "Save failed")
|
||||
viewModel.clearSaveResult()
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.showPlaylistPicker) {
|
||||
PlaylistPickerDialog(
|
||||
playlists = playlists,
|
||||
newPlaylistName = uiState.newPlaylistName,
|
||||
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
|
||||
onPlaylistSelected = viewModel::addToPlaylist,
|
||||
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
|
||||
onDismiss = viewModel::dismissPlaylistPicker,
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = onNavigateToStations,
|
||||
icon = { Icon(Icons.Default.Radio, contentDescription = "Stations") },
|
||||
label = { Text("Stations") },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = onNavigateToLibrary,
|
||||
icon = { Icon(Icons.Default.LibraryMusic, contentDescription = "Library") },
|
||||
label = { Text("Library") },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = onNavigateToStreams,
|
||||
icon = { Icon(Icons.Default.Podcasts, contentDescription = "Streams") },
|
||||
label = { Text("Streams") },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = onNavigateToPlaylists,
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = "Playlists") },
|
||||
label = { Text("Playlists") },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (uiState.currentStation != null) {
|
||||
NowPlayingView(
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
} else {
|
||||
EmptyStateView(
|
||||
onBrowseStations = onNavigateToStations,
|
||||
onImportMusic = onNavigateToLibrary,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyStateView(
|
||||
onBrowseStations: () -> Unit,
|
||||
onImportMusic: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
WaveGraphic(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = "Find your sound",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PremiumText,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Choose a station or add music\nto start listening",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = PremiumTextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 24.sp,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
Button(
|
||||
onClick = onBrowseStations,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PremiumPurple,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Browse stations",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onImportMusic,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = PremiumPurpleLight,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.LibraryMusic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Import music",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaveGraphic(modifier: Modifier = Modifier) {
|
||||
val gradientColors = listOf(GradientStart, GradientMid, GradientEnd)
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val centerY = height / 2
|
||||
|
||||
// Draw multiple wave layers
|
||||
for (layer in 0..2) {
|
||||
val path = Path()
|
||||
val amplitude = (30f - layer * 8f) * (width / 400f)
|
||||
val frequency = 0.008f + layer * 0.002f
|
||||
val phaseShift = layer * 0.5f
|
||||
|
||||
path.moveTo(0f, centerY)
|
||||
|
||||
for (x in 0..width.toInt()) {
|
||||
val xFloat = x.toFloat()
|
||||
val y = centerY + amplitude * kotlin.math.sin(
|
||||
(xFloat * frequency * Math.PI * 2).toDouble() + phaseShift
|
||||
).toFloat()
|
||||
if (x == 0) {
|
||||
path.moveTo(xFloat, y)
|
||||
} else {
|
||||
path.lineTo(xFloat, y)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
|
||||
startX = 0f,
|
||||
endX = width,
|
||||
),
|
||||
style = Stroke(width = (3f - layer * 0.5f) * (width / 400f)),
|
||||
)
|
||||
}
|
||||
|
||||
// Draw subtle glow at center
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(PremiumPurple.copy(alpha = 0.15f), Color.Transparent),
|
||||
center = Offset(width / 2, centerY),
|
||||
radius = width / 3,
|
||||
),
|
||||
radius = width / 3,
|
||||
center = Offset(width / 2, centerY),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NowPlayingView(
|
||||
uiState: PlayerUiState,
|
||||
viewModel: PlayerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Station card
|
||||
uiState.currentStation?.let { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
bufferState = uiState.bufferState,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Artist/Station name
|
||||
AnimatedContent(
|
||||
targetState = uiState.artist,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "artist",
|
||||
) { artist ->
|
||||
Text(
|
||||
text = artist.ifEmpty { "No station" },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// Song title
|
||||
AnimatedContent(
|
||||
targetState = uiState.title,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "title",
|
||||
) { title ->
|
||||
if (title.isNotEmpty()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
||||
Text(
|
||||
text = if (uiState.hasMetadata) "" else "No metadata",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Save button
|
||||
SaveSongButton(
|
||||
canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY,
|
||||
saveState = uiState.saveState,
|
||||
onClick = { viewModel.saveSong() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Playback controls
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { viewModel.previousStation() },
|
||||
enabled = uiState.currentStation != null,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.stop() },
|
||||
enabled = uiState.currentStation != null,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Stop,
|
||||
contentDescription = "Stop",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.togglePlayPause() },
|
||||
enabled = uiState.currentStation != null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
if (uiState.isPlaying) {
|
||||
Icon(
|
||||
Icons.Default.Pause,
|
||||
contentDescription = "Pause",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistPickerDialog(
|
||||
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
|
||||
newPlaylistName: String,
|
||||
onNewPlaylistNameChange: (String) -> Unit,
|
||||
onPlaylistSelected: (Long) -> Unit,
|
||||
onCreatePlaylist: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var showNewPlaylistInput by remember { mutableStateOf(false) }
|
||||
var playlistSearchQuery by remember { mutableStateOf("") }
|
||||
|
||||
val filteredPlaylists = remember(playlists, playlistSearchQuery) {
|
||||
if (playlistSearchQuery.isBlank()) playlists
|
||||
else playlists.filter { it.name.contains(playlistSearchQuery, ignoreCase = true) }
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.PlaylistAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = { Text("Add to Playlist") },
|
||||
text = {
|
||||
Column {
|
||||
if (playlists.isNotEmpty()) {
|
||||
OutlinedTextField(
|
||||
value = playlistSearchQuery,
|
||||
onValueChange = { playlistSearchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search playlists...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = PremiumSurface,
|
||||
unfocusedContainerColor = PremiumSurface,
|
||||
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (filteredPlaylists.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
) {
|
||||
items(filteredPlaylists) { playlist ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPlaylistSelected(playlist.id) }
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.PlaylistAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = playlist.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = PremiumText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (playlists.isNotEmpty()) {
|
||||
Text(
|
||||
text = "No matching playlists",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PremiumTextSecondary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "No playlists yet. Create one below.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PremiumTextSecondary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (showNewPlaylistInput) {
|
||||
OutlinedTextField(
|
||||
value = newPlaylistName,
|
||||
onValueChange = onNewPlaylistNameChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Playlist name") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (newPlaylistName.isNotBlank()) {
|
||||
onCreatePlaylist()
|
||||
showNewPlaylistInput = false
|
||||
playlistSearchQuery = ""
|
||||
}
|
||||
},
|
||||
enabled = newPlaylistName.isNotBlank(),
|
||||
) {
|
||||
Text("Create & Add")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { showNewPlaylistInput = true },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("New Playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveSongButton(
|
||||
canSave: Boolean,
|
||||
saveState: PlaybackManager.SaveState,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val containerColor = when {
|
||||
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
||||
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
||||
saveState == PlaybackManager.SaveState.ERROR -> MaterialTheme.colorScheme.error
|
||||
canSave -> PremiumPurple
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
val contentColor = when {
|
||||
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.onSecondary
|
||||
saveState == PlaybackManager.SaveState.SUCCESS -> Color.White
|
||||
saveState == PlaybackManager.SaveState.ERROR -> MaterialTheme.colorScheme.onError
|
||||
canSave -> Color.White
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
enabled = canSave && saveState != PlaybackManager.SaveState.SAVING,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(20.dp)),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = containerColor.copy(alpha = 0.5f),
|
||||
disabledContentColor = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = saveState,
|
||||
transitionSpec = {
|
||||
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
|
||||
scaleOut(targetScale = 0.8f) + fadeOut()
|
||||
},
|
||||
label = "saveIcon",
|
||||
) { state ->
|
||||
when (state) {
|
||||
PlaybackManager.SaveState.SAVING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = contentColor,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
}
|
||||
PlaybackManager.SaveState.SUCCESS -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Saved",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
PlaybackManager.SaveState.ERROR -> {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
PlaybackManager.SaveState.IDLE -> {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = "Save Song",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StationCard(
|
||||
station: Station,
|
||||
bufferState: PlaybackManager.BufferState,
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (station.genre.isNotEmpty()) {
|
||||
Text(
|
||||
text = station.genre,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
BufferIndicator(bufferState = bufferState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BufferIndicator(bufferState: PlaybackManager.BufferState) {
|
||||
val (text, color) = when (bufferState) {
|
||||
PlaybackManager.BufferState.IDLE -> "" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
PlaybackManager.BufferState.INITIALIZING -> "Init" to PremiumCyan
|
||||
PlaybackManager.BufferState.BUFFERING -> "Buffering" to PremiumCyan
|
||||
PlaybackManager.BufferState.READY -> "Ready" to MaterialTheme.colorScheme.primary
|
||||
PlaybackManager.BufferState.ERROR -> "Error" to MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
262
app/src/main/java/com/rada/ui/player/PlayerViewModel.kt
Normal file
262
app/src/main/java/com/rada/ui/player/PlayerViewModel.kt
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package com.rada.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.repository.PlaylistRepository
|
||||
import com.rada.data.repository.StationRepository
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.player.PlaybackManager
|
||||
import com.rada.recording.SaveSongUseCase
|
||||
import com.rada.recording.StreamMetadata
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val playbackManager: PlaybackManager,
|
||||
private val stationRepository: StationRepository,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
val playlists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val playbackListener = object : PlaybackManager.PlaybackListener {
|
||||
override fun onPlaybackStateChanged(isPlaying: Boolean) {
|
||||
_uiState.update { it.copy(isPlaying = isPlaying) }
|
||||
}
|
||||
|
||||
override fun onMetadataUpdate(artist: String, title: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
artist = artist,
|
||||
title = title,
|
||||
hasMetadata = artist.isNotEmpty() || title.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
_uiState.update { it.copy(error = message) }
|
||||
}
|
||||
|
||||
override fun onIcyMetadata(metadata: StreamMetadata) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
lastMetadata = metadata,
|
||||
hasMetadata = metadata.artist.isNotEmpty() || metadata.title.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBufferStateChanged(state: PlaybackManager.BufferState) {
|
||||
_uiState.update { it.copy(bufferState = state) }
|
||||
}
|
||||
|
||||
override fun onBufferBytesWritten(totalBytes: Long) {
|
||||
_uiState.update { it.copy(bufferBytesWritten = totalBytes) }
|
||||
}
|
||||
|
||||
override fun onSaveStateChanged(state: PlaybackManager.SaveState, result: SaveSongUseCase.SaveResult?) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
saveState = state,
|
||||
lastSaveResult = result,
|
||||
)
|
||||
}
|
||||
if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showPlaylistPicker = true,
|
||||
pickedSongId = result.songId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStationChanged(name: String, url: String) {
|
||||
val station = stationRepository.getAll().find { it.url == url && it.name == name }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentStation = station ?: Station(id = url, name = name, url = url),
|
||||
artist = name,
|
||||
title = "",
|
||||
hasMetadata = false,
|
||||
lastMetadata = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
playbackManager.listener = playbackListener
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stations = stationRepository.getAll(),
|
||||
currentStation = playbackManager.currentStationUrl?.let { url ->
|
||||
Station(id = url, name = playbackManager.currentStationName, url = url)
|
||||
},
|
||||
isPlaying = playbackManager.isPlaying,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun playStation(station: Station) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentStation = station,
|
||||
artist = station.name,
|
||||
title = "",
|
||||
hasMetadata = false,
|
||||
lastMetadata = null,
|
||||
)
|
||||
}
|
||||
playbackManager.playStation(station.url, station.name)
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
playbackManager.togglePlayPause()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playbackManager.stop()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
artist = it.currentStation?.name ?: "",
|
||||
title = "",
|
||||
hasMetadata = false,
|
||||
lastMetadata = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun nextStation() {
|
||||
val stations = _uiState.value.stations
|
||||
if (stations.isEmpty()) return
|
||||
val current = _uiState.value.currentStation
|
||||
val idx = stations.indexOfFirst { it.id == current?.id }
|
||||
val next = if (idx < 0 || idx >= stations.lastIndex) stations.first() else stations[idx + 1]
|
||||
playStation(next)
|
||||
}
|
||||
|
||||
fun previousStation() {
|
||||
val stations = _uiState.value.stations
|
||||
if (stations.isEmpty()) return
|
||||
val current = _uiState.value.currentStation
|
||||
val idx = stations.indexOfFirst { it.id == current?.id }
|
||||
val prev = if (idx <= 0) stations.last() else stations[idx - 1]
|
||||
playStation(prev)
|
||||
}
|
||||
|
||||
fun saveSong() {
|
||||
playbackManager.saveCurrentSong()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun clearSaveResult() {
|
||||
_uiState.update { it.copy(lastSaveResult = null) }
|
||||
}
|
||||
|
||||
fun dismissPlaylistPicker() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showPlaylistPicker = false,
|
||||
pickedSongId = null,
|
||||
newPlaylistName = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToPlaylist(playlistId: Long) {
|
||||
val songId = _uiState.value.pickedSongId ?: return
|
||||
viewModelScope.launch {
|
||||
playlistRepository.addSongToPlaylist(playlistId, songId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showPlaylistPicker = false,
|
||||
pickedSongId = null,
|
||||
newPlaylistName = "",
|
||||
playlistPickerMessage = "Added to playlist",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylistAndAdd(name: String) {
|
||||
val songId = _uiState.value.pickedSongId ?: return
|
||||
viewModelScope.launch {
|
||||
val playlistId = playlistRepository.createPlaylist(name)
|
||||
playlistRepository.addSongToPlaylist(playlistId, songId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showPlaylistPicker = false,
|
||||
pickedSongId = null,
|
||||
newPlaylistName = "",
|
||||
playlistPickerMessage = "Created \"$name\" and added song",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewPlaylistName(name: String) {
|
||||
_uiState.update { it.copy(newPlaylistName = name) }
|
||||
}
|
||||
|
||||
fun clearPlaylistPickerMessage() {
|
||||
_uiState.update { it.copy(playlistPickerMessage = null) }
|
||||
}
|
||||
|
||||
fun syncPlaybackState() {
|
||||
playbackManager.listener = playbackListener
|
||||
val url = playbackManager.currentStationUrl
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stations = stationRepository.getAll(),
|
||||
currentStation = url?.let { u ->
|
||||
Station(id = u, name = playbackManager.currentStationName, url = u)
|
||||
},
|
||||
isPlaying = playbackManager.isPlaying,
|
||||
artist = playbackManager.currentStationName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
playbackManager.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerUiState(
|
||||
val currentStation: Station? = null,
|
||||
val isPlaying: Boolean = false,
|
||||
val artist: String = "",
|
||||
val title: String = "",
|
||||
val hasMetadata: Boolean = false,
|
||||
val lastMetadata: StreamMetadata? = null,
|
||||
val error: String? = null,
|
||||
val stations: List<Station> = emptyList(),
|
||||
val bufferState: PlaybackManager.BufferState = PlaybackManager.BufferState.IDLE,
|
||||
val bufferBytesWritten: Long = 0,
|
||||
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
|
||||
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
|
||||
val showPlaylistPicker: Boolean = false,
|
||||
val pickedSongId: Long? = null,
|
||||
val newPlaylistName: String = "",
|
||||
val playlistPickerMessage: String? = null,
|
||||
)
|
||||
399
app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt
Normal file
399
app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
package com.rada.ui.playlists
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumPurpleLight
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumSurfaceVariant
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistManagerScreen(
|
||||
onBack: () -> Unit,
|
||||
onPlaylistSelected: (Long) -> Unit = {},
|
||||
viewModel: PlaylistManagerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val playlists by viewModel.playlists.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val sortOrder by viewModel.sortOrder.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<PlaylistEntity?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<PlaylistEntity?>(null) }
|
||||
|
||||
LaunchedEffect(uiState.message) {
|
||||
uiState.message?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreatePlaylistDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name ->
|
||||
viewModel.createPlaylist(name)
|
||||
showCreateDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showRenameDialog && renameTarget != null) {
|
||||
RenamePlaylistDialog(
|
||||
currentName = renameTarget!!.name,
|
||||
onDismiss = { showRenameDialog = false },
|
||||
onRename = { newName ->
|
||||
viewModel.renamePlaylist(renameTarget!!, newName)
|
||||
showRenameDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteDialog && deleteTarget != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Playlist") },
|
||||
text = { Text("Delete \"${deleteTarget!!.name}\"? Songs won't be deleted.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.deletePlaylist(deleteTarget!!)
|
||||
showDeleteDialog = false
|
||||
}) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Playlists") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showSortMenu = !showSortMenu }) {
|
||||
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort")
|
||||
}
|
||||
IconButton(onClick = { showCreateDialog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New Playlist")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.search(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search playlists...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = PremiumSurface,
|
||||
unfocusedContainerColor = PremiumSurface,
|
||||
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
Box {
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Name A–Z") },
|
||||
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_ASC); showSortMenu = false },
|
||||
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_ASC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Name Z–A") },
|
||||
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_DESC); showSortMenu = false },
|
||||
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_DESC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Newest first") },
|
||||
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_NEWEST); showSortMenu = false },
|
||||
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_NEWEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Oldest first") },
|
||||
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_OLDEST); showSortMenu = false },
|
||||
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_OLDEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (playlists.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.PlaylistPlay,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = PremiumPurple.copy(alpha = 0.3f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = if (searchQuery.isNotBlank()) "No matching playlists" else "No playlists yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Create Playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
PlaylistListItem(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistSelected(playlist.id) },
|
||||
onRename = {
|
||||
renameTarget = playlist
|
||||
showRenameDialog = true
|
||||
},
|
||||
onDelete = {
|
||||
deleteTarget = playlist
|
||||
showDeleteDialog = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistListItem(
|
||||
playlist: PlaylistEntity,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.PlaylistPlay,
|
||||
contentDescription = null,
|
||||
tint = PremiumPurple,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = playlist.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PremiumText,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePlaylistDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New Playlist") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Playlist name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCreate(name) },
|
||||
enabled = name.isNotBlank(),
|
||||
) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenamePlaylistDialog(
|
||||
currentName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf(currentName) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename Playlist") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("New name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onRename(name) },
|
||||
enabled = name.isNotBlank() && name != currentName,
|
||||
) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.rada.ui.playlists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.data.repository.PlaylistRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class PlaylistSortOrder { NAME_ASC, NAME_DESC, DATE_NEWEST, DATE_OLDEST }
|
||||
|
||||
@HiltViewModel
|
||||
class PlaylistManagerViewModel @Inject constructor(
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PlaylistManagerUiState())
|
||||
val uiState: StateFlow<PlaylistManagerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _sortOrder = MutableStateFlow(PlaylistSortOrder.NAME_ASC)
|
||||
val sortOrder: StateFlow<PlaylistSortOrder> = _sortOrder.asStateFlow()
|
||||
|
||||
private val allPlaylists = playlistRepository.getAllPlaylists()
|
||||
|
||||
val playlists: StateFlow<List<PlaylistEntity>> = combine(
|
||||
allPlaylists, _searchQuery, _sortOrder,
|
||||
) { list, query, sort ->
|
||||
val filtered = if (query.isBlank()) list
|
||||
else list.filter { it.name.contains(query, ignoreCase = true) }
|
||||
when (sort) {
|
||||
PlaylistSortOrder.NAME_ASC -> filtered.sortedBy { it.name.lowercase() }
|
||||
PlaylistSortOrder.NAME_DESC -> filtered.sortedByDescending { it.name.lowercase() }
|
||||
PlaylistSortOrder.DATE_NEWEST -> filtered.sortedByDescending { it.id }
|
||||
PlaylistSortOrder.DATE_OLDEST -> filtered.sortedBy { it.id }
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun search(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun setSortOrder(order: PlaylistSortOrder) {
|
||||
_sortOrder.value = order
|
||||
}
|
||||
|
||||
fun createPlaylist(name: String) {
|
||||
if (name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
playlistRepository.createPlaylist(name.trim())
|
||||
_uiState.update { it.copy(message = "Created \"$name\"") }
|
||||
}
|
||||
}
|
||||
|
||||
fun renamePlaylist(playlist: PlaylistEntity, newName: String) {
|
||||
if (newName.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
playlistRepository.deletePlaylist(playlist)
|
||||
playlistRepository.createPlaylist(newName.trim())
|
||||
_uiState.update { it.copy(message = "Renamed to \"$newName\"") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlaylist(playlist: PlaylistEntity) {
|
||||
viewModelScope.launch {
|
||||
playlistRepository.deletePlaylist(playlist)
|
||||
_uiState.update { it.copy(message = "Deleted \"${playlist.name}\"") }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_uiState.update { it.copy(message = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaylistManagerUiState(
|
||||
val message: String? = null,
|
||||
)
|
||||
441
app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt
Normal file
441
app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package com.rada.ui.stations
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumPurpleLight
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumSurfaceVariant
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
|
||||
private val genreColors = mapOf(
|
||||
"Lofi" to 0xFF7C3AED,
|
||||
"Synthwave" to 0xFFEC4899,
|
||||
"EDM" to 0xFF06B6D4,
|
||||
"Phonk" to 0xFFEF4444,
|
||||
"Classical" to 0xFFF59E0B,
|
||||
"Strings" to 0xFF10B981,
|
||||
"Ambient" to 0xFF3B82F6,
|
||||
"Electronic" to 0xFF06B6D4,
|
||||
"Jazz" to 0xFFF59E0B,
|
||||
"Eclectic" to 0xFFEC4899,
|
||||
"Oldies" to 0xFFF59E0B,
|
||||
"Rock" to 0xFFEF4444,
|
||||
"Hip-Hop" to 0xFFEC4899,
|
||||
"Custom" to 0xFF7C3AED,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StationBrowserScreen(
|
||||
onBack: () -> Unit,
|
||||
onStationSelected: () -> Unit = {},
|
||||
viewModel: StationBrowserViewModel = hiltViewModel(),
|
||||
) {
|
||||
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
||||
|
||||
if (showAddDialog) {
|
||||
AddStationDialog(
|
||||
onDismiss = { showAddDialog = false },
|
||||
onAdd = { name, url ->
|
||||
viewModel.addCustomStation(name, url)
|
||||
showAddDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
renameTarget?.let { target ->
|
||||
RenameStationDialog(
|
||||
station = target,
|
||||
isRenamed = viewModel.isRenamed(target.id),
|
||||
originalName = viewModel.getOriginalStationName(target.id),
|
||||
onDismiss = { renameTarget = null },
|
||||
onRename = { newName ->
|
||||
viewModel.renameStation(target.id, newName)
|
||||
renameTarget = null
|
||||
},
|
||||
onReset = {
|
||||
viewModel.resetStationName(target.id)
|
||||
renameTarget = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Stations") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showAddDialog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add station URL")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.search(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search stations...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = PremiumSurface,
|
||||
unfocusedContainerColor = PremiumSurface,
|
||||
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
stationsByGenre.forEach { (genre, stations) ->
|
||||
item {
|
||||
GenreHeader(genre = genre, count = stations.size)
|
||||
}
|
||||
items(stations, key = { it.id }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
isCustom = viewModel.isCustomStation(station.id),
|
||||
onClick = {
|
||||
viewModel.selectStation(station)
|
||||
onStationSelected()
|
||||
},
|
||||
onRename = { renameTarget = station },
|
||||
onDelete = {
|
||||
viewModel.removeCustomStation(station.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddStationDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (name: String, url: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = { Text("Add Station URL") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Station name") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Stream URL") },
|
||||
placeholder = { Text("https://...") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onAdd(name, url) },
|
||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameStationDialog(
|
||||
station: Station,
|
||||
isRenamed: Boolean,
|
||||
originalName: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
var name by remember(station.id) { mutableStateOf(station.name) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = { Text("Rename Station") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Station name") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
if (isRenamed && originalName != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Renamed from \"$originalName\"",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onRename(name) },
|
||||
enabled = name.isNotBlank() && name != station.name,
|
||||
) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
if (isRenamed) {
|
||||
TextButton(onClick = onReset) {
|
||||
Text("Reset")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreHeader(genre: String, count: Int) {
|
||||
val colorInt = genreColors.entries.find { (key, _) ->
|
||||
genre.contains(key, ignoreCase = true)
|
||||
}?.value ?: 0xFF7C3AED
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(androidx.compose.ui.graphics.Color(colorInt)),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = genre.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = androidx.compose.ui.graphics.Color(colorInt),
|
||||
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "$count",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StationCard(
|
||||
station: Station,
|
||||
isCustom: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = PremiumSurfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PremiumPurple.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = PremiumPurpleLight,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PremiumText,
|
||||
)
|
||||
if (station.genre.isNotEmpty()) {
|
||||
Text(
|
||||
text = station.genre,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = PremiumTextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (isCustom) {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = PremiumTextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.rada.ui.stations
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.repository.StationRepository
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StationBrowserViewModel @Inject constructor(
|
||||
private val stationRepository: StationRepository,
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _allStations = MutableStateFlow(stationRepository.getAll())
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
|
||||
val filtered = if (query.isBlank()) list
|
||||
else list.filter {
|
||||
it.name.contains(query, ignoreCase = true) ||
|
||||
it.genre.contains(query, ignoreCase = true) ||
|
||||
it.url.contains(query, ignoreCase = true)
|
||||
}
|
||||
filtered.sortedBy { it.name.lowercase() }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), stationRepository.getAll())
|
||||
|
||||
val stationsByGenre: StateFlow<Map<String, List<Station>>> = combine(_allStations, _searchQuery) { list, query ->
|
||||
val filtered = if (query.isBlank()) list
|
||||
else list.filter {
|
||||
it.name.contains(query, ignoreCase = true) ||
|
||||
it.genre.contains(query, ignoreCase = true) ||
|
||||
it.url.contains(query, ignoreCase = true)
|
||||
}
|
||||
filtered.sortedBy { it.name.lowercase() }
|
||||
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
|
||||
.toSortedMap(compareBy { it.lowercase() })
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
fun search(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun selectStation(station: Station) {
|
||||
playbackManager.playStation(station.url, station.name)
|
||||
}
|
||||
|
||||
fun addCustomStation(name: String, url: String) {
|
||||
if (name.isBlank() || url.isBlank()) return
|
||||
val id = "custom-${System.currentTimeMillis()}"
|
||||
val station = Station(
|
||||
id = id,
|
||||
name = name.trim(),
|
||||
url = url.trim(),
|
||||
genre = "Custom",
|
||||
)
|
||||
stationRepository.addStation(station)
|
||||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
fun removeCustomStation(id: String) {
|
||||
stationRepository.removeStation(id)
|
||||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
fun renameStation(id: String, newName: String) {
|
||||
stationRepository.renameStation(id, newName)
|
||||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
fun resetStationName(id: String) {
|
||||
stationRepository.resetStationName(id)
|
||||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
fun isRenamed(id: String): Boolean = stationRepository.isRenamed(id)
|
||||
|
||||
fun getOriginalStationName(id: String): String? =
|
||||
stationRepository.getOriginalStationName(id)
|
||||
|
||||
fun isCustomStation(id: String): Boolean {
|
||||
return stationRepository.isCustomStation(id)
|
||||
}
|
||||
}
|
||||
460
app/src/main/java/com/rada/ui/streams/StreamsScreen.kt
Normal file
460
app/src/main/java/com/rada/ui/streams/StreamsScreen.kt
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package com.rada.ui.streams
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.StreamEntry
|
||||
import com.rada.ui.theme.PremiumBorder
|
||||
import com.rada.ui.theme.PremiumPurple
|
||||
import com.rada.ui.theme.PremiumPurpleLight
|
||||
import com.rada.ui.theme.PremiumSurface
|
||||
import com.rada.ui.theme.PremiumSurfaceVariant
|
||||
import com.rada.ui.theme.PremiumText
|
||||
import com.rada.ui.theme.PremiumTextSecondary
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamsScreen(
|
||||
onBack: () -> Unit,
|
||||
onStreamSelected: () -> Unit = {},
|
||||
viewModel: StreamsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val groupedEntries by viewModel.groupedEntries.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val isFetching by viewModel.isFetching.collectAsState()
|
||||
val fetchStatus by viewModel.fetchStatus.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val totalCount by viewModel.totalCount.collectAsState()
|
||||
val currentStreamUrl by viewModel.currentStreamUrl.collectAsState()
|
||||
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<StreamEntry?>(null) }
|
||||
|
||||
if (showAddDialog) {
|
||||
AddStreamDialog(
|
||||
onDismiss = { showAddDialog = false },
|
||||
onAdd = { name, url ->
|
||||
viewModel.addStream(url)
|
||||
showAddDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
renameTarget?.let { target ->
|
||||
RenameStreamDialog(
|
||||
entry = target,
|
||||
onDismiss = { renameTarget = null },
|
||||
onRename = { newName ->
|
||||
viewModel.rename(target.id, newName)
|
||||
renameTarget = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Streams") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showAddDialog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add stream URL")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.search(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search streams...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = PremiumSurface,
|
||||
unfocusedContainerColor = PremiumSurface,
|
||||
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
// Status / error row
|
||||
val statusText = error ?: fetchStatus
|
||||
if (statusText.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isFetching) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = PremiumPurple,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (error != null) Color(0xFFEF4444) else PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount == 0 && !isFetching) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = PremiumTextSecondary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "No streams yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = PremiumText,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Tap + to add a stream URL or an .m3u playlist",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
groupedEntries.forEach { (group, entries) ->
|
||||
item(key = "header-$group") {
|
||||
GroupHeader(group = group, count = entries.size)
|
||||
}
|
||||
items(entries, key = { it.id }) { entry ->
|
||||
StreamCard(
|
||||
entry = entry,
|
||||
isPlaying = currentStreamUrl == entry.url,
|
||||
onClick = {
|
||||
viewModel.play(entry)
|
||||
onStreamSelected()
|
||||
},
|
||||
onRename = { renameTarget = entry },
|
||||
onDelete = { viewModel.remove(entry.id) },
|
||||
)
|
||||
}
|
||||
item(key = "spacer-$group") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddStreamDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (name: String, url: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = { Text("Add Stream") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Stream URL or playlist (.m3u / .m3u8 / .pls)") },
|
||||
placeholder = { Text("https://example.com/stream.m3u") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Display name (optional)") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Paste a direct stream URL, or an .m3u/.pls playlist to add all of its streams.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onAdd(name, url) },
|
||||
enabled = url.isNotBlank(),
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameStreamDialog(
|
||||
entry: StreamEntry,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
) {
|
||||
var name by remember(entry.id) { mutableStateOf(entry.name) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = { Text("Rename Stream") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Display name") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PremiumPurple,
|
||||
unfocusedBorderColor = PremiumBorder,
|
||||
focusedTextColor = PremiumText,
|
||||
unfocusedTextColor = PremiumText,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onRename(name) },
|
||||
enabled = name.isNotBlank() && name != entry.name,
|
||||
) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(group: String, count: Int) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PremiumPurple),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = group.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PremiumPurpleLight,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "$count",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = PremiumTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamCard(
|
||||
entry: StreamEntry,
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isPlaying) PremiumPurple.copy(alpha = 0.12f) else PremiumSurfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isPlaying) PremiumPurple else PremiumPurple.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = if (isPlaying) Color.White else PremiumPurpleLight,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PremiumText,
|
||||
)
|
||||
Text(
|
||||
text = entry.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = PremiumTextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = PremiumTextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = PremiumTextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/com/rada/ui/streams/StreamsViewModel.kt
Normal file
119
app/src/main/java/com/rada/ui/streams/StreamsViewModel.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package com.rada.ui.streams
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.repository.StreamFetcher
|
||||
import com.rada.data.repository.StreamRepository
|
||||
import com.rada.domain.model.StreamEntry
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StreamsViewModel @Inject constructor(
|
||||
private val streamRepository: StreamRepository,
|
||||
private val streamFetcher: StreamFetcher,
|
||||
private val playbackManager: PlaybackManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _allEntries = MutableStateFlow(streamRepository.getAll())
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
private val _isFetching = MutableStateFlow(false)
|
||||
private val _fetchStatus = MutableStateFlow("")
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
|
||||
val isFetching: StateFlow<Boolean> = _isFetching.asStateFlow()
|
||||
val fetchStatus: StateFlow<String> = _fetchStatus.asStateFlow()
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
/** URL currently playing (reactive) — drives the play/pause highlight. */
|
||||
val currentStreamUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||
|
||||
/** Grouped, search-filtered entries: group name -> entries (sorted). */
|
||||
val groupedEntries: StateFlow<Map<String, List<StreamEntry>>> =
|
||||
combine(_allEntries, _searchQuery) { list, query ->
|
||||
groupAndFilter(list, query)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
private fun groupAndFilter(list: List<StreamEntry>, query: String): Map<String, List<StreamEntry>> {
|
||||
val filtered = if (query.isBlank()) list else list.filter {
|
||||
it.name.contains(query, true) || it.url.contains(query, true) || it.group.contains(query, true)
|
||||
}
|
||||
return filtered
|
||||
.groupBy { it.group }
|
||||
.mapValues { (_, entries) -> entries.sortedBy { e -> e.name.lowercase() } }
|
||||
.toSortedMap(compareBy { it.lowercase() })
|
||||
}
|
||||
|
||||
/** Total number of entries currently stored. */
|
||||
val totalCount: StateFlow<Int> = _allEntries.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
fun search(query: String) { _searchQuery.value = query }
|
||||
|
||||
/**
|
||||
* Add a stream URL (or m3u / pls playlist URL). Fetches + parses it,
|
||||
* merges the resulting entries into the library, and reports the result.
|
||||
*/
|
||||
fun addStream(url: String) {
|
||||
if (url.isBlank() || _isFetching.value) return
|
||||
viewModelScope.launch {
|
||||
_error.value = null
|
||||
_isFetching.value = true
|
||||
_fetchStatus.value = "Fetching…"
|
||||
try {
|
||||
val entries = streamFetcher.fetch(url)
|
||||
if (entries.isEmpty()) {
|
||||
_fetchStatus.value = ""
|
||||
_error.value = "No streams found at that URL"
|
||||
} else {
|
||||
streamRepository.addAll(entries)
|
||||
_allEntries.value = streamRepository.getAll()
|
||||
_fetchStatus.value = "Added ${entries.size} stream${if (entries.size == 1) "" else "s"}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_fetchStatus.value = ""
|
||||
_error.value = "Couldn't fetch: ${e.message ?: "unknown error"}"
|
||||
} finally {
|
||||
_isFetching.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(entry: StreamEntry) {
|
||||
playbackManager.playStation(entry.url, entry.name)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playbackManager.stop()
|
||||
}
|
||||
|
||||
fun isPlayingUrl(url: String): Boolean {
|
||||
return playbackManager.currentStationUrl == url
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
streamRepository.remove(id)
|
||||
_allEntries.value = streamRepository.getAll()
|
||||
}
|
||||
|
||||
fun rename(id: String, newName: String) {
|
||||
streamRepository.rename(id, newName)
|
||||
_allEntries.value = streamRepository.getAll()
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
streamRepository.clear()
|
||||
_allEntries.value = streamRepository.getAll()
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/com/rada/ui/theme/Color.kt
Normal file
56
app/src/main/java/com/rada/ui/theme/Color.kt
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package com.rada.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Premium dark palette ──
|
||||
val PremiumBlack = Color(0xFF0A0A14)
|
||||
val PremiumDark = Color(0xFF12121E)
|
||||
val PremiumSurface = Color(0xFF1A1A2E)
|
||||
val PremiumSurfaceVariant = Color(0xFF222238)
|
||||
val PremiumBorder = Color(0xFF2A2A44)
|
||||
|
||||
val PremiumPurple = Color(0xFF7C3AED)
|
||||
val PremiumPurpleLight = Color(0xFFA78BFA)
|
||||
val PremiumBlue = Color(0xFF3B82F6)
|
||||
val PremiumCyan = Color(0xFF06B6D4)
|
||||
val PremiumPink = Color(0xFFEC4899)
|
||||
|
||||
val PremiumText = Color(0xFFF0F0F5)
|
||||
val PremiumTextSecondary = Color(0xFF9CA3AF)
|
||||
val PremiumTextMuted = Color(0xFF6B7280)
|
||||
|
||||
val PremiumSuccess = Color(0xFF10B981)
|
||||
val PremiumError = Color(0xFFEF4444)
|
||||
val PremiumWarning = Color(0xFFF59E0B)
|
||||
|
||||
// Gradient colors for accents
|
||||
val GradientStart = Color(0xFF7C3AED)
|
||||
val GradientMid = Color(0xFF3B82F6)
|
||||
val GradientEnd = Color(0xFF06B6D4)
|
||||
|
||||
// Legacy terminal palette (cliamp-inspired)
|
||||
val TerminalBlack = Color(0xFF000000)
|
||||
val TerminalDarkGray = Color(0xFF0A0A0A)
|
||||
val TerminalGray = Color(0xFF1A1A1A)
|
||||
val TerminalMidGray = Color(0xFF2A2A2A)
|
||||
|
||||
val TerminalGreen = Color(0xFF00FF41)
|
||||
val TerminalGreenDim = Color(0xFF00CC33)
|
||||
val TerminalGreenMuted = Color(0xFF007A1F)
|
||||
|
||||
val TerminalGold = Color(0xFFFFD700)
|
||||
val TerminalGoldDim = Color(0xFFCC9900)
|
||||
|
||||
val TerminalWhite = Color(0xFFE0E0E0)
|
||||
val TerminalLightGray = Color(0xFFAAAAAA)
|
||||
val TerminalDarkText = Color(0xFF666666)
|
||||
|
||||
val TerminalRed = Color(0xFFFF5555)
|
||||
val TerminalCyan = Color(0xFF00D4FF)
|
||||
val TerminalMagenta = Color(0xFFFF55FF)
|
||||
|
||||
// Legacy aliases used by existing code
|
||||
val SaveButton = PremiumSuccess
|
||||
val SaveButtonDisabled = PremiumSurfaceVariant
|
||||
val SaveConfirm = PremiumSuccess
|
||||
val ErrorRed = PremiumError
|
||||
69
app/src/main/java/com/rada/ui/theme/Theme.kt
Normal file
69
app/src/main/java/com/rada/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package com.rada.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val PremiumColorScheme = darkColorScheme(
|
||||
primary = PremiumPurple,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = PremiumPurple.copy(alpha = 0.15f),
|
||||
onPrimaryContainer = PremiumPurpleLight,
|
||||
|
||||
secondary = PremiumBlue,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = PremiumBlue.copy(alpha = 0.15f),
|
||||
onSecondaryContainer = PremiumBlue,
|
||||
|
||||
tertiary = PremiumCyan,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = PremiumCyan.copy(alpha = 0.15f),
|
||||
onTertiaryContainer = PremiumCyan,
|
||||
|
||||
background = PremiumBlack,
|
||||
onBackground = PremiumText,
|
||||
|
||||
surface = PremiumDark,
|
||||
onSurface = PremiumText,
|
||||
surfaceVariant = PremiumSurface,
|
||||
onSurfaceVariant = PremiumTextSecondary,
|
||||
|
||||
error = PremiumError,
|
||||
onError = Color.White,
|
||||
errorContainer = PremiumError.copy(alpha = 0.15f),
|
||||
onErrorContainer = PremiumError,
|
||||
|
||||
outline = PremiumBorder,
|
||||
outlineVariant = PremiumBorder.copy(alpha = 0.5f),
|
||||
inverseSurface = PremiumText,
|
||||
inverseOnSurface = PremiumBlack,
|
||||
inversePrimary = PremiumPurpleLight,
|
||||
surfaceTint = PremiumPurple,
|
||||
)
|
||||
|
||||
object RadAmpGradients {
|
||||
val primary = Brush.linearGradient(
|
||||
colors = listOf(GradientStart, GradientMid, GradientEnd)
|
||||
)
|
||||
val primaryHorizontal = Brush.horizontalGradient(
|
||||
colors = listOf(GradientStart, GradientMid, GradientEnd)
|
||||
)
|
||||
val subtle = Brush.verticalGradient(
|
||||
colors = listOf(PremiumSurface, PremiumBlack)
|
||||
)
|
||||
val cardGlow = Brush.radialGradient(
|
||||
colors = listOf(PremiumPurple.copy(alpha = 0.1f), Color.Transparent)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadAmpTheme(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = PremiumColorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 379 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 B |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 707 B |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 975 B |
9
app/src/main/res/values/strings.xml
Normal file
9
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">RadAmp</string>
|
||||
<string name="playback_channel_name">Playback</string>
|
||||
<string name="playback_channel_description">Radio playback controls</string>
|
||||
<string name="notification_play">Play</string>
|
||||
<string name="notification_pause">Pause</string>
|
||||
<string name="notification_stop">Stop</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.RadAmp" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue