feat: tabbed station libraries, SomaFM integration, and settings panel

Add tabbed playlist UI with SomaFM as a built-in library including live
listener counts, station hiding, and stream quality selection. Implement
settings panel with quality preferences, listening history, and playlist
import/export improvements. Includes DB migrations 1-4, SomaFM seed
data, stream resolver, and now-playing history logging.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 20:16:09 -04:00
parent 5dd7a411ed
commit 6481d74d95
23 changed files with 2411 additions and 300 deletions

View File

@@ -65,6 +65,8 @@ dependencies {
implementation(libs.material)
implementation(libs.coil.compose)
implementation(libs.coil.network)
implementation(libs.cloudy)
implementation(libs.palette)
testImplementation(libs.junit)
testImplementation(libs.mockk)

View File

@@ -0,0 +1,333 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "399ac2cc6c24a53359d4df8e2b89af3e",
"entities": [
{
"tableName": "stations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `playlistId` INTEGER, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `defaultArtworkUrl` TEXT, `listenerCount` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultArtworkUrl",
"columnName": "defaultArtworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "listenerCount",
"columnName": "listenerCount",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stations_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stations_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `isBuiltIn` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBuiltIn",
"columnName": "isBuiltIn",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "metadata_snapshots",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `title` TEXT, `artist` TEXT, `artworkUrl` TEXT, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "artworkUrl",
"columnName": "artworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_metadata_snapshots_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_stationId` ON `${TABLE_NAME}` (`stationId`)"
},
{
"name": "index_metadata_snapshots_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "listening_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_listening_sessions_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_stationId` ON `${TABLE_NAME}` (`stationId`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "connection_spans",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`sessionId`) REFERENCES `listening_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_connection_spans_sessionId",
"unique": false,
"columnNames": [
"sessionId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_connection_spans_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
}
],
"foreignKeys": [
{
"table": "listening_sessions",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sessionId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '399ac2cc6c24a53359d4df8e2b89af3e')"
]
}
}

View File

@@ -0,0 +1,339 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "457080c5ca807a6242ac9b38b9878bd9",
"entities": [
{
"tableName": "stations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `playlistId` INTEGER, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `defaultArtworkUrl` TEXT, `listenerCount` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultArtworkUrl",
"columnName": "defaultArtworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "listenerCount",
"columnName": "listenerCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isHidden",
"columnName": "isHidden",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stations_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stations_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `isBuiltIn` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBuiltIn",
"columnName": "isBuiltIn",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "metadata_snapshots",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `title` TEXT, `artist` TEXT, `artworkUrl` TEXT, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "artworkUrl",
"columnName": "artworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_metadata_snapshots_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_stationId` ON `${TABLE_NAME}` (`stationId`)"
},
{
"name": "index_metadata_snapshots_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "listening_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_listening_sessions_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_stationId` ON `${TABLE_NAME}` (`stationId`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "connection_spans",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`sessionId`) REFERENCES `listening_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_connection_spans_sessionId",
"unique": false,
"columnNames": [
"sessionId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_connection_spans_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
}
],
"foreignKeys": [
{
"table": "listening_sessions",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sessionId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '457080c5ca807a6242ac9b38b9878bd9')"
]
}
}

View File

@@ -0,0 +1,410 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "a54797b339dbfab8a9cbc2882bf91e0b",
"entities": [
{
"tableName": "stations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `playlistId` INTEGER, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `defaultArtworkUrl` TEXT, `listenerCount` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `qualityOverride` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultArtworkUrl",
"columnName": "defaultArtworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "listenerCount",
"columnName": "listenerCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isHidden",
"columnName": "isHidden",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "qualityOverride",
"columnName": "qualityOverride",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stations_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stations_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `isBuiltIn` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBuiltIn",
"columnName": "isBuiltIn",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "metadata_snapshots",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `title` TEXT, `artist` TEXT, `artworkUrl` TEXT, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "artworkUrl",
"columnName": "artworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_metadata_snapshots_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_stationId` ON `${TABLE_NAME}` (`stationId`)"
},
{
"name": "index_metadata_snapshots_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "listening_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_listening_sessions_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_stationId` ON `${TABLE_NAME}` (`stationId`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "connection_spans",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`sessionId`) REFERENCES `listening_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_connection_spans_sessionId",
"unique": false,
"columnNames": [
"sessionId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_connection_spans_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
}
],
"foreignKeys": [
{
"table": "listening_sessions",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sessionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "station_streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `bitrate` INTEGER NOT NULL, `ssl` INTEGER NOT NULL, `url` TEXT NOT NULL, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bitrate",
"columnName": "bitrate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ssl",
"columnName": "ssl",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_station_streams_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_station_streams_stationId` ON `${TABLE_NAME}` (`stationId`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a54797b339dbfab8a9cbc2882bf91e0b')"
]
}
}

View File

@@ -3,14 +3,19 @@ package xyz.cottongin.radio247
import android.app.Application
import androidx.room.Room
import xyz.cottongin.radio247.data.db.RadioDatabase
import xyz.cottongin.radio247.data.db.SomaFmSeedData
import xyz.cottongin.radio247.data.logging.NowPlayingHistoryWriter
import xyz.cottongin.radio247.data.prefs.RadioPreferences
import xyz.cottongin.radio247.metadata.AlbumArtResolver
import xyz.cottongin.radio247.service.RadioController
import xyz.cottongin.radio247.service.StreamResolver
import okhttp3.OkHttpClient
class RadioApplication : Application() {
val database: RadioDatabase by lazy {
Room.databaseBuilder(this, RadioDatabase::class.java, "radio_database")
.addCallback(SomaFmSeedData)
.addMigrations(RadioDatabase.MIGRATION_1_2, RadioDatabase.MIGRATION_2_3, RadioDatabase.MIGRATION_3_4)
.build()
}
@@ -29,4 +34,12 @@ class RadioApplication : Application() {
val albumArtResolver: AlbumArtResolver by lazy {
AlbumArtResolver(okHttpClient)
}
val streamResolver: StreamResolver by lazy {
StreamResolver(database.stationStreamDao(), preferences)
}
val historyWriter: NowPlayingHistoryWriter by lazy {
NowPlayingHistoryWriter(this, preferences)
}
}

View File

@@ -0,0 +1,49 @@
package xyz.cottongin.radio247.data.api
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
object SomaFmApi {
private const val CHANNELS_URL = "https://somafm.com/channels.json"
/**
* Fetches live listener counts from SomaFM.
* Returns a map of channel id (e.g. "groovesalad") to listener count.
*/
suspend fun fetchListenerCounts(client: OkHttpClient): Map<String, Int> =
withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(CHANNELS_URL)
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
val body = response.use { it.body?.string() } ?: return@withContext emptyMap()
val json = JSONObject(body)
val channels = json.getJSONArray("channels")
val result = mutableMapOf<String, Int>()
for (i in 0 until channels.length()) {
val channel = channels.getJSONObject(i)
val id = channel.getString("id")
val listeners = channel.optInt("listeners", 0)
result[id] = listeners
}
result
}
/**
* Extracts the SomaFM stream ID from a station URL.
* e.g. "https://ice1.somafm.com/groovesalad-256-mp3" -> "groovesalad"
*/
fun extractStreamId(stationUrl: String): String? {
val path = Uri.parse(stationUrl).lastPathSegment ?: return null
val id = path.substringBefore('-')
return id.ifEmpty { null }
}
}

View File

@@ -2,11 +2,14 @@ package xyz.cottongin.radio247.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import xyz.cottongin.radio247.data.model.ConnectionSpan
import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream
@Database(
entities = [
@@ -14,9 +17,10 @@ import xyz.cottongin.radio247.data.model.Station
Playlist::class,
MetadataSnapshot::class,
ListeningSession::class,
ConnectionSpan::class
ConnectionSpan::class,
StationStream::class
],
version = 1,
version = 4,
exportSchema = true
)
abstract class RadioDatabase : RoomDatabase() {
@@ -25,4 +29,39 @@ abstract class RadioDatabase : RoomDatabase() {
abstract fun metadataSnapshotDao(): MetadataSnapshotDao
abstract fun listeningSessionDao(): ListeningSessionDao
abstract fun connectionSpanDao(): ConnectionSpanDao
abstract fun stationStreamDao(): StationStreamDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE stations ADD COLUMN listenerCount INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE playlists ADD COLUMN isBuiltIn INTEGER NOT NULL DEFAULT 0")
SomaFmSeedData.seedStations(db, includeIsHidden = false, includeStreams = false)
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE stations ADD COLUMN isHidden INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS station_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
stationId INTEGER NOT NULL,
bitrate INTEGER NOT NULL,
ssl INTEGER NOT NULL,
url TEXT NOT NULL,
FOREIGN KEY(stationId) REFERENCES stations(id) ON DELETE CASCADE
)"""
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_station_streams_stationId ON station_streams(stationId)")
db.execSQL("ALTER TABLE stations ADD COLUMN qualityOverride TEXT DEFAULT NULL")
SomaFmSeedData.seedStreamsForExistingStations(db)
}
}
}
}

View File

@@ -0,0 +1,159 @@
package xyz.cottongin.radio247.data.db
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Seeds the database with all SomaFM stations on first creation.
* Station data sourced from https://somafm.com/channels.json.
* Sorted alphabetically by name; listener counts from a March 2026 snapshot.
*/
object SomaFmSeedData : RoomDatabase.Callback() {
private data class SeedStation(
val name: String,
val streamId: String,
val maxBitrate: Int,
val artworkUrl: String,
val listeners: Int
) {
val has256: Boolean get() = maxBitrate >= 256
}
private val STATIONS = listOf(
SeedStation("Beat Blender", "beatblender", 128, "https://api.somafm.com/logos/256/beatblender256.png", 162),
SeedStation("Black Rock FM", "brfm", 128, "https://api.somafm.com/logos/256/brfm256.jpg", 14),
SeedStation("Boot Liquor", "bootliquor", 320, "https://api.somafm.com/logos/256/bootliquor256.png", 73),
SeedStation("Bossa Beyond", "bossa", 256, "https://api.somafm.com/logos/256/bossa-256.jpg", 84),
SeedStation("Chillits Radio", "chillits", 256, "https://api.somafm.com/logos/256/chillits256.png", 6),
SeedStation("Covers", "covers", 128, "https://api.somafm.com/logos/256/covers256.png", 37),
SeedStation("DEF CON Radio", "defcon", 256, "https://api.somafm.com/logos/256/defcon256.png", 204),
SeedStation("Deep Space One", "deepspaceone", 128, "https://api.somafm.com/logos/256/deepspaceone256.png", 457),
SeedStation("Digitalis", "digitalis", 256, "https://api.somafm.com/logos/256/digitalis256.png", 39),
SeedStation("Doomed", "doomed", 256, "https://api.somafm.com/logos/256/doomed256.png", 20),
SeedStation("Drone Zone", "dronezone", 256, "https://api.somafm.com/logos/256/dronezone256.png", 1298),
SeedStation("Dub Step Beyond", "dubstep", 256, "https://api.somafm.com/logos/256/dubstep256.png", 46),
SeedStation("Fluid", "fluid", 128, "https://api.somafm.com/logos/256/fluid256.jpg", 68),
SeedStation("Folk Forward", "folkfwd", 128, "https://api.somafm.com/logos/256/folkfwd256.png", 151),
SeedStation("Groove Salad", "groovesalad", 256, "https://api.somafm.com/logos/256/groovesalad256.png", 1893),
SeedStation("Groove Salad Classic", "gsclassic", 128, "https://api.somafm.com/logos/256/gsclassic256.jpg", 303),
SeedStation("Heavyweight Reggae", "reggae", 256, "https://api.somafm.com/logos/256/reggae256.png", 69),
SeedStation("Illinois Street Lounge", "illstreet", 128, "https://api.somafm.com/logos/256/illstreet256.jpg", 71),
SeedStation("Indie Pop Rocks!", "indiepop", 128, "https://api.somafm.com/logos/256/indiepop256.png", 424),
SeedStation("Left Coast 70s", "seventies", 320, "https://api.somafm.com/logos/256/seventies256.jpg", 226),
SeedStation("Lush", "lush", 128, "https://api.somafm.com/logos/256/lush256.png", 299),
SeedStation("Metal Detector", "metal", 128, "https://api.somafm.com/logos/256/metal256.png", 38),
SeedStation("Mission Control", "missioncontrol", 128, "https://api.somafm.com/logos/256/missioncontrol256.png", 49),
SeedStation("PopTron", "poptron", 128, "https://api.somafm.com/logos/256/poptron256.png", 103),
SeedStation("SF 10-33", "sf1033", 128, "https://api.somafm.com/logos/256/sf1033256.png", 47),
SeedStation("SF Police Scanner", "scanner", 128, "https://api.somafm.com/logos/256/scanner256.png", 14),
SeedStation("SF in SF", "sfinsf", 128, "https://api.somafm.com/logos/256/sfinsf256.jpg", 3),
SeedStation("Secret Agent", "secretagent", 128, "https://api.somafm.com/logos/256/secretagent256.png", 223),
SeedStation("Seven Inch Soul", "7soul", 128, "https://api.somafm.com/logos/256/7soul256.png", 74),
SeedStation("SomaFM Live", "live", 128, "https://api.somafm.com/logos/256/live256.png", 14),
SeedStation("SomaFM Specials", "specials", 128, "https://api.somafm.com/logos/256/specials256.png", 18),
SeedStation("Sonic Universe", "sonicuniverse", 256, "https://api.somafm.com/logos/256/sonicuniverse256.png", 92),
SeedStation("Space Station Soma", "spacestation", 320, "https://api.somafm.com/logos/256/spacestation256.png", 386),
SeedStation("Suburbs of Goa", "suburbsofgoa", 128, "https://api.somafm.com/logos/256/suburbsofgoa256.png", 84),
SeedStation("Synphaera Radio", "synphaera", 256, "https://api.somafm.com/logos/256/synphaera256.jpg", 257),
SeedStation("The Dark Zone", "darkzone", 256, "https://api.somafm.com/logos/256/darkzone256.jpg", 140),
SeedStation("The In-Sound", "insound", 256, "https://api.somafm.com/logos/256/insound-256.jpg", 17),
SeedStation("The Trip", "thetrip", 128, "https://api.somafm.com/logos/256/thetrip256.jpg", 98),
SeedStation("ThistleRadio", "thistle", 128, "https://api.somafm.com/logos/256/thistle256.jpg", 52),
SeedStation("Tiki Time", "tikitime", 256, "https://api.somafm.com/logos/256/tikitime256.jpg", 24),
SeedStation("Underground 80s", "u80s", 256, "https://api.somafm.com/logos/256/u80s256.png", 243),
SeedStation("Vaporwaves", "vaporwaves", 128, "https://api.somafm.com/logos/256/vaporwaves256.png", 55),
SeedStation("cliqhop idm", "cliqhop", 256, "https://api.somafm.com/logos/256/cliqhop256.png", 55),
SeedStation("n5MD Radio", "n5md", 128, "https://api.somafm.com/logos/256/n5md256.png", 25),
)
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
seedStations(db)
}
/**
* Inserts the SomaFM playlist and all stations.
* @param includeIsHidden true for v3+ schema, false for MIGRATION_1_2.
* @param includeStreams true for v4+ schema (fresh install), false otherwise.
*/
fun seedStations(
db: SupportSQLiteDatabase,
includeIsHidden: Boolean = true,
includeStreams: Boolean = true
) {
db.execSQL(
"INSERT INTO playlists (name, sortOrder, starred, isBuiltIn) VALUES ('SomaFM', 0, 0, 1)"
)
val cursor = db.query("SELECT last_insert_rowid()")
cursor.moveToFirst()
val playlistId = cursor.getLong(0)
cursor.close()
val qualityCol = if (includeStreams) ", qualityOverride" else ""
val qualityVal = if (includeStreams) ", NULL" else ""
STATIONS.forEachIndexed { index, station ->
val url = "https://ice1.somafm.com/${station.streamId}-${station.maxBitrate}-mp3"
val escapedName = station.name.replace("'", "''")
if (includeIsHidden) {
db.execSQL(
"""INSERT INTO stations (name, url, playlistId, sortOrder, starred, defaultArtworkUrl, listenerCount, isHidden$qualityCol)
VALUES ('$escapedName', '$url', $playlistId, $index, 0, '${station.artworkUrl}', ${station.listeners}, 0$qualityVal)"""
)
} else {
db.execSQL(
"""INSERT INTO stations (name, url, playlistId, sortOrder, starred, defaultArtworkUrl, listenerCount)
VALUES ('$escapedName', '$url', $playlistId, $index, 0, '${station.artworkUrl}', ${station.listeners})"""
)
}
if (includeStreams) {
val stationCursor = db.query("SELECT last_insert_rowid()")
stationCursor.moveToFirst()
val stationId = stationCursor.getLong(0)
stationCursor.close()
insertStreamsForStation(db, stationId, station.streamId, station.has256)
}
}
}
/**
* Seeds station_streams for SomaFM stations that already exist in the DB.
* Used by MIGRATION_3_4 when upgrading from v3 to v4.
*/
fun seedStreamsForExistingStations(db: SupportSQLiteDatabase) {
STATIONS.forEach { seed ->
val cursor = db.query(
"SELECT id FROM stations WHERE url LIKE '%${seed.streamId}%' LIMIT 1"
)
if (cursor.moveToFirst()) {
val stationId = cursor.getLong(0)
insertStreamsForStation(db, stationId, seed.streamId, seed.has256)
}
cursor.close()
}
}
private fun insertStreamsForStation(
db: SupportSQLiteDatabase,
stationId: Long,
streamId: String,
has256: Boolean
) {
if (has256) {
db.execSQL(
"INSERT INTO station_streams (stationId, bitrate, ssl, url) VALUES ($stationId, 256, 1, 'https://ice1.somafm.com/$streamId-256-mp3')"
)
db.execSQL(
"INSERT INTO station_streams (stationId, bitrate, ssl, url) VALUES ($stationId, 256, 0, 'http://ice1.somafm.com/$streamId-256-mp3')"
)
}
db.execSQL(
"INSERT INTO station_streams (stationId, bitrate, ssl, url) VALUES ($stationId, 128, 1, 'https://ice1.somafm.com/$streamId-128-mp3')"
)
db.execSQL(
"INSERT INTO station_streams (stationId, bitrate, ssl, url) VALUES ($stationId, 128, 0, 'http://ice1.somafm.com/$streamId-128-mp3')"
)
}
}

View File

@@ -13,12 +13,24 @@ interface StationDao {
@Query("SELECT * FROM stations ORDER BY starred DESC, sortOrder ASC")
fun getAllStations(): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId = :playlistId ORDER BY starred DESC, sortOrder ASC")
@Query("SELECT * FROM stations WHERE playlistId = :playlistId AND isHidden = 0 ORDER BY starred DESC, sortOrder ASC")
fun getStationsByPlaylist(playlistId: Long): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId IS NULL ORDER BY starred DESC, sortOrder ASC")
@Query("SELECT * FROM stations WHERE playlistId IS NULL AND isHidden = 0 ORDER BY starred DESC, sortOrder ASC")
fun getUnsortedStations(): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId = :playlistId AND isHidden = 1 ORDER BY sortOrder ASC")
fun getHiddenStationsByPlaylist(playlistId: Long): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId IS NULL AND isHidden = 1 ORDER BY sortOrder ASC")
fun getHiddenUnsortedStations(): Flow<List<Station>>
@Query("SELECT COUNT(*) FROM stations WHERE playlistId = :playlistId AND isHidden = 1")
fun getHiddenCountByPlaylist(playlistId: Long): Flow<Int>
@Query("SELECT COUNT(*) FROM stations WHERE playlistId IS NULL AND isHidden = 1")
fun getHiddenUnsortedCount(): Flow<Int>
@Query("SELECT * FROM stations WHERE id = :id")
suspend fun getStationById(id: Long): Station?
@@ -36,4 +48,10 @@ interface StationDao {
@Query("UPDATE stations SET starred = :starred WHERE id = :id")
suspend fun toggleStarred(id: Long, starred: Boolean)
@Query("UPDATE stations SET isHidden = :isHidden WHERE id = :id")
suspend fun toggleHidden(id: Long, isHidden: Boolean)
@Query("UPDATE stations SET listenerCount = :count WHERE id = :id")
suspend fun updateListenerCount(id: Long, count: Int)
}

View File

@@ -0,0 +1,15 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import xyz.cottongin.radio247.data.model.StationStream
@Dao
interface StationStreamDao {
@Query("SELECT * FROM station_streams WHERE stationId = :stationId")
suspend fun getStreamsForStation(stationId: Long): List<StationStream>
@Insert
suspend fun insertAll(streams: List<StationStream>)
}

View File

@@ -0,0 +1,140 @@
package xyz.cottongin.radio247.data.logging
import android.content.Context
import android.net.Uri
import android.os.Environment
import xyz.cottongin.radio247.data.prefs.RadioPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class NowPlayingHistoryWriter(
private val context: Context,
private val preferences: RadioPreferences
) {
private val mutex = Mutex()
suspend fun append(station: String, artist: String?, title: String?, timestamp: Long) {
val enabled = preferences.historyEnabled.first()
if (!enabled) return
val format = preferences.historyFormat.first()
val line = formatLine(format, station, artist, title, timestamp)
mutex.withLock {
withContext(Dispatchers.IO) {
val outputStream = openOutputStream(format)
outputStream?.use { os ->
os.write(line.toByteArray())
os.write("\n".toByteArray())
}
}
}
}
private suspend fun openOutputStream(format: String): OutputStream? {
val dirUri = preferences.historyDirUri.first()
val fileName = fileNameForFormat(format)
return if (dirUri.isNotEmpty()) {
try {
val treeUri = Uri.parse(dirUri)
val docUri = findOrCreateFile(treeUri, fileName)
docUri?.let { context.contentResolver.openOutputStream(it, "wa") }
} catch (_: Exception) {
fallbackOutputStream(fileName)
}
} else {
fallbackOutputStream(fileName)
}
}
private fun fallbackOutputStream(fileName: String): OutputStream? {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return null
val file = File(dir, fileName)
return FileOutputStream(file, true)
}
private fun findOrCreateFile(treeUri: Uri, fileName: String): Uri? {
val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri,
android.provider.DocumentsContract.getTreeDocumentId(treeUri)
)
val cursor = context.contentResolver.query(
childrenUri,
arrayOf(
android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID,
android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
),
null, null, null
)
cursor?.use {
while (it.moveToNext()) {
val name = it.getString(1)
if (name == fileName) {
val docId = it.getString(0)
return android.provider.DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
}
}
}
val parentDocId = android.provider.DocumentsContract.getTreeDocumentId(treeUri)
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
val mimeType = when {
fileName.endsWith(".csv") -> "text/csv"
fileName.endsWith(".jsonl") -> "application/jsonl"
else -> "text/plain"
}
return android.provider.DocumentsContract.createDocument(
context.contentResolver, parentUri, mimeType, fileName
)
}
companion object {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
fun fileNameForFormat(format: String): String = when (format) {
"json" -> "now-playing-history.jsonl"
"plain" -> "now-playing-history.txt"
else -> "now-playing-history.csv"
}
fun formatLine(
format: String,
station: String,
artist: String?,
title: String?,
timestamp: Long
): String {
val date = Date(timestamp)
return when (format) {
"json" -> {
val ts = isoFormat.format(date)
val a = (artist ?: "").replace("\"", "\\\"")
val t = (title ?: "").replace("\"", "\\\"")
val s = station.replace("\"", "\\\"")
"""{"timestamp":"$ts","station":"$s","artist":"$a","title":"$t"}"""
}
"plain" -> {
val ts = dateFormat.format(date)
val track = listOfNotNull(artist, title).joinToString(" - ").ifEmpty { "Unknown" }
"$ts | $station | $track"
}
else -> {
val ts = dateFormat.format(date)
"\"$ts\",\"${station.csvEscape()}\",\"${(artist ?: "").csvEscape()}\",\"${(title ?: "").csvEscape()}\""
}
}
}
private fun String.csvEscape(): String = replace("\"", "\"\"")
}
}

View File

@@ -8,5 +8,6 @@ data class Playlist(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val sortOrder: Int = 0,
val starred: Boolean = false
val starred: Boolean = false,
val isBuiltIn: Boolean = false
)

View File

@@ -22,5 +22,8 @@ data class Station(
val playlistId: Long? = null,
val sortOrder: Int = 0,
val starred: Boolean = false,
val defaultArtworkUrl: String? = null
val defaultArtworkUrl: String? = null,
val listenerCount: Int = 0,
val isHidden: Boolean = false,
val qualityOverride: String? = null
)

View File

@@ -0,0 +1,24 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "station_streams",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId")]
)
data class StationStream(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val bitrate: Int,
val ssl: Boolean,
val url: String
)

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -16,6 +17,13 @@ class RadioPreferences(private val context: Context) {
val bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] }
val qualityPreference: Flow<String> = dataStore.data.map {
it[QUALITY_PREFERENCE] ?: DEFAULT_QUALITY_ORDER
}
val historyEnabled: Flow<Boolean> = dataStore.data.map { it[HISTORY_ENABLED] ?: false }
val historyFormat: Flow<String> = dataStore.data.map { it[HISTORY_FORMAT] ?: "csv" }
val historyDirUri: Flow<String> = dataStore.data.map { it[HISTORY_DIR_URI] ?: "" }
suspend fun setStayConnected(value: Boolean) {
dataStore.edit { it[STAY_CONNECTED] = value }
}
@@ -28,10 +36,32 @@ class RadioPreferences(private val context: Context) {
dataStore.edit { it[LAST_STATION_ID] = value }
}
suspend fun setQualityPreference(value: String) {
dataStore.edit { it[QUALITY_PREFERENCE] = value }
}
suspend fun setHistoryEnabled(value: Boolean) {
dataStore.edit { it[HISTORY_ENABLED] = value }
}
suspend fun setHistoryFormat(value: String) {
dataStore.edit { it[HISTORY_FORMAT] = value }
}
suspend fun setHistoryDirUri(value: String) {
dataStore.edit { it[HISTORY_DIR_URI] = value }
}
companion object {
private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
private val BUFFER_MS = intPreferencesKey("buffer_ms")
private val LAST_STATION_ID = longPreferencesKey("last_station_id")
private val QUALITY_PREFERENCE = stringPreferencesKey("quality_preference")
private val HISTORY_ENABLED = booleanPreferencesKey("history_enabled")
private val HISTORY_FORMAT = stringPreferencesKey("history_format")
private val HISTORY_DIR_URI = stringPreferencesKey("history_dir_uri")
const val DEFAULT_QUALITY_ORDER = """["256-ssl","256-nossl","128-ssl","128-nossl"]"""
}
}

View File

@@ -0,0 +1,44 @@
package xyz.cottongin.radio247.service
import xyz.cottongin.radio247.data.db.StationStreamDao
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.prefs.RadioPreferences
import kotlinx.coroutines.flow.first
import org.json.JSONArray
class StreamResolver(
private val streamDao: StationStreamDao,
private val preferences: RadioPreferences
) {
/**
* Returns an ordered list of stream URLs to try for the given station.
* For SomaFM stations: orders by quality preference (per-station or global).
* For user-added stations: returns the single stored URL.
*/
suspend fun resolveUrls(station: Station): List<String> {
val streams = streamDao.getStreamsForStation(station.id)
if (streams.isEmpty()) return listOf(station.url)
val order = parseOrder(station.qualityOverride ?: preferences.qualityPreference.first())
val sorted = streams.sortedBy { stream ->
val key = "${stream.bitrate}-${if (stream.ssl) "ssl" else "nossl"}"
val idx = order.indexOf(key)
if (idx >= 0) idx else Int.MAX_VALUE
}
return sorted.map { it.url }
}
private fun parseOrder(json: String): List<String> {
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) {
DEFAULT_ORDER
}
}
companion object {
val DEFAULT_ORDER = listOf("256-ssl", "256-nossl", "128-ssl", "128-nossl")
val DEFAULT_ORDER_JSON = JSONArray(DEFAULT_ORDER).toString()
}
}

View File

@@ -5,22 +5,29 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -40,11 +47,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -59,26 +66,43 @@ fun SettingsScreen(
) {
val stayConnected by viewModel.stayConnected.collectAsState()
val bufferMs by viewModel.bufferMs.collectAsState()
val qualityPref by viewModel.qualityPreference.collectAsState()
val historyEnabled by viewModel.historyEnabled.collectAsState()
val historyFormat by viewModel.historyFormat.collectAsState()
val historyDirUri by viewModel.historyDirUri.collectAsState()
val recentSessions by viewModel.recentSessions.collectAsState()
val filteredTracks by viewModel.filteredTracks.collectAsState()
val stations by viewModel.stations.collectAsState()
var trackHistoryQuery by remember { mutableStateOf("") }
val isRestarting by viewModel.isRestarting.collectAsState()
var trackHistoryQuery by remember { mutableStateOf("") }
var showExportDialog by remember { mutableStateOf(false) }
var showResetDialog by remember { mutableStateOf(false) }
var resetAlsoDeleteStations by remember { mutableStateOf(false) }
val qualityOrder = remember(qualityPref) { viewModel.parseQualityOrder(qualityPref) }
val createDocumentM3u = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl")
) { uri: Uri? ->
uri?.let {
viewModel.exportPlaylist(null, stations, "m3u", it)
}
uri?.let { viewModel.exportPlaylist(null, stations, "m3u", it) }
}
val createDocumentPls = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-scpls")
) { uri: Uri? ->
uri?.let { viewModel.exportPlaylist(null, stations, "pls", it) }
}
val pickHistoryDir = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri: Uri? ->
uri?.let {
viewModel.exportPlaylist(null, stations, "pls", it)
val context = viewModel.getApplication<xyz.cottongin.radio247.RadioApplication>()
val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(it, flags)
viewModel.setHistoryDirUri(it.toString())
}
}
@@ -87,10 +111,7 @@ fun SettingsScreen(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -106,22 +127,12 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// ── PLAYBACK ──
SectionHeader("PLAYBACK")
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Stay Connected")
Switch(
checked = stayConnected,
onCheckedChange = { viewModel.setStayConnected(it) }
)
SettingRow("Stay Connected") {
Switch(checked = stayConnected, onCheckedChange = { viewModel.setStayConnected(it) })
}
Text(
text = "Buffer: ${bufferMs}ms",
style = MaterialTheme.typography.bodySmall
)
Text("Buffer: ${bufferMs}ms", style = MaterialTheme.typography.bodySmall)
Slider(
value = bufferMs.toFloat(),
onValueChange = { viewModel.setBufferMs(it.toInt()) },
@@ -136,16 +147,70 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
SectionHeader("EXPORT")
// ── SOMAFM QUALITY ──
SectionHeader("SOMAFM QUALITY")
Text(
"Drag to reorder stream quality preference. The app will try each option in order, falling back to the next if unavailable.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
QualityOrderList(
order = qualityOrder,
onReorder = { viewModel.setQualityPreference(it) }
)
Spacer(modifier = Modifier.height(24.dp))
// ── NOW PLAYING HISTORY ──
SectionHeader("NOW PLAYING HISTORY")
SettingRow("Enable History Logging") {
Switch(checked = historyEnabled, onCheckedChange = { viewModel.setHistoryEnabled(it) })
}
if (historyEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Format", style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("csv" to "CSV", "json" to "JSON", "plain" to "Plain Text").forEach { (key, label) ->
FilterChip(
selected = historyFormat == key,
onClick = { viewModel.setHistoryFormat(key) },
label = { Text(label) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (historyDirUri.isNotEmpty()) {
val uri = Uri.parse(historyDirUri)
uri.lastPathSegment ?: historyDirUri
} else {
"Default (app storage)"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Button(
onClick = { showExportDialog = true },
onClick = { pickHistoryDir.launch(null) },
modifier = Modifier.fillMaxWidth()
) {
Text("Choose Directory")
}
}
Spacer(modifier = Modifier.height(24.dp))
// ── EXPORT ──
SectionHeader("EXPORT")
Button(onClick = { showExportDialog = true }, modifier = Modifier.fillMaxWidth()) {
Text("Export Playlist")
}
Spacer(modifier = Modifier.height(24.dp))
// ── RECENTLY PLAYED ──
SectionHeader("RECENTLY PLAYED")
val stationMap = stations.associateBy { it.id }
LazyColumn(
@@ -162,6 +227,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// ── TRACK HISTORY ──
SectionHeader("TRACK HISTORY")
OutlinedTextField(
value = trackHistoryQuery,
@@ -185,16 +251,58 @@ fun SettingsScreen(
)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
// ── RESET ──
SectionHeader("RESET")
SettingRow("Also delete saved stations/playlists") {
Switch(
checked = resetAlsoDeleteStations,
onCheckedChange = { resetAlsoDeleteStations = it }
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showResetDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text("Reset All Customizations")
}
Spacer(modifier = Modifier.height(24.dp))
// ── RESTART ──
SectionHeader("APP")
Button(
onClick = { viewModel.restartApp() },
modifier = Modifier.fillMaxWidth(),
enabled = !isRestarting
) {
if (isRestarting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("Restart App")
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
// ── DIALOGS ──
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
confirmButton = {
Button(onClick = { showExportDialog = false }) {
Text("Cancel")
}
Button(onClick = { showExportDialog = false }) { Text("Cancel") }
},
title = { Text("Export format") },
text = {
@@ -205,9 +313,7 @@ fun SettingsScreen(
createDocumentM3u.launch("playlist.m3u")
},
modifier = Modifier.fillMaxWidth()
) {
Text("M3U")
}
) { Text("M3U") }
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
@@ -215,20 +321,118 @@ fun SettingsScreen(
createDocumentPls.launch("playlist.pls")
},
modifier = Modifier.fillMaxWidth()
) {
Text("PLS")
) { Text("PLS") }
}
}
)
}
if (showResetDialog) {
val message = if (resetAlsoDeleteStations) {
"This will clear all stars, custom sort orders, hidden states, and quality overrides. All user-added stations and playlists will be deleted. This cannot be undone."
} else {
"This will clear all stars, custom sort orders, hidden states, and quality overrides. Your saved stations will be kept. This cannot be undone."
}
AlertDialog(
onDismissRequest = { showResetDialog = false },
confirmButton = {
Button(
onClick = {
showResetDialog = false
viewModel.resetCustomizations(resetAlsoDeleteStations)
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) { Text("Reset") }
},
dismissButton = {
Button(onClick = { showResetDialog = false }) { Text("Cancel") }
},
title = { Text("Reset Customizations") },
text = { Text(message) }
)
}
}
@Composable
private fun SectionHeader(
title: String,
modifier: Modifier = Modifier
private fun SettingRow(
label: String,
modifier: Modifier = Modifier,
control: @Composable () -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = label, modifier = Modifier.weight(1f))
control()
}
}
@Composable
private fun QualityOrderList(
order: List<String>,
onReorder: (List<String>) -> Unit
) {
val labels = mapOf(
"256-ssl" to "256 kbps (SSL)",
"256-nossl" to "256 kbps",
"128-ssl" to "128 kbps (SSL)",
"128-nossl" to "128 kbps"
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
order.forEachIndexed { index, key ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${index + 1}.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = labels[key] ?: key,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
if (index > 0) {
val mutable = order.toMutableList()
mutable[index] = mutable[index - 1].also { mutable[index - 1] = mutable[index] }
onReorder(mutable)
}
},
enabled = index > 0
) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Move up", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = {
if (index < order.lastIndex) {
val mutable = order.toMutableList()
mutable[index] = mutable[index + 1].also { mutable[index + 1] = mutable[index] }
onReorder(mutable)
}
},
enabled = index < order.lastIndex
) {
Icon(Icons.Default.ArrowDownward, contentDescription = "Move down", modifier = Modifier.size(20.dp))
}
}
}
}
}
@Composable
private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
@@ -283,10 +487,7 @@ private fun TrackHistoryRow(
val timestamp = formatRelativeTime(snapshot.timestamp)
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = trackInfo,
style = MaterialTheme.typography.bodyMedium
)
Text(text = trackInfo, style = MaterialTheme.typography.bodyMedium)
Text(
text = "$stationName | $timestamp",
style = MaterialTheme.typography.bodySmall,

View File

@@ -1,35 +1,53 @@
package xyz.cottongin.radio247.ui.screens.settings
import android.app.Application
import kotlinx.coroutines.ExperimentalCoroutinesApi
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.MainActivity
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.importing.PlaylistExporter
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.prefs.RadioPreferences
import xyz.cottongin.radio247.service.PlaybackState
import xyz.cottongin.radio247.service.StreamResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication
val stayConnected = app.preferences.stayConnected.stateIn(
viewModelScope,
SharingStarted.Lazily,
false
viewModelScope, SharingStarted.Lazily, false
)
val bufferMs = app.preferences.bufferMs.stateIn(
viewModelScope,
SharingStarted.Lazily,
0
viewModelScope, SharingStarted.Lazily, 0
)
val qualityPreference = app.preferences.qualityPreference.stateIn(
viewModelScope, SharingStarted.Lazily, RadioPreferences.DEFAULT_QUALITY_ORDER
)
val historyEnabled = app.preferences.historyEnabled.stateIn(
viewModelScope, SharingStarted.Lazily, false
)
val historyFormat = app.preferences.historyFormat.stateIn(
viewModelScope, SharingStarted.Lazily, "csv"
)
val historyDirUri = app.preferences.historyDirUri.stateIn(
viewModelScope, SharingStarted.Lazily, ""
)
val recentSessions = app.database.listeningSessionDao()
@@ -51,16 +69,32 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.getAllStations()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val _isRestarting = MutableStateFlow(false)
val isRestarting: StateFlow<Boolean> = _isRestarting
fun setStayConnected(value: Boolean) {
viewModelScope.launch {
app.preferences.setStayConnected(value)
}
viewModelScope.launch { app.preferences.setStayConnected(value) }
}
fun setBufferMs(value: Int) {
viewModelScope.launch {
app.preferences.setBufferMs(value)
viewModelScope.launch { app.preferences.setBufferMs(value) }
}
fun setQualityPreference(orderedKeys: List<String>) {
val json = JSONArray(orderedKeys).toString()
viewModelScope.launch { app.preferences.setQualityPreference(json) }
}
fun setHistoryEnabled(value: Boolean) {
viewModelScope.launch { app.preferences.setHistoryEnabled(value) }
}
fun setHistoryFormat(value: String) {
viewModelScope.launch { app.preferences.setHistoryFormat(value) }
}
fun setHistoryDirUri(value: String) {
viewModelScope.launch { app.preferences.setHistoryDirUri(value) }
}
fun exportPlaylist(
@@ -84,4 +118,69 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setTrackHistoryQuery(query: String) {
trackHistory.value = query
}
fun resetCustomizations(alsoDeleteStations: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
val db = app.database.openHelper.writableDatabase
db.beginTransaction()
try {
db.execSQL("UPDATE stations SET starred = 0, isHidden = 0, qualityOverride = NULL")
val cursor = db.query("SELECT id, sortOrder FROM stations ORDER BY id")
var idx = 0
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
db.execSQL("UPDATE stations SET sortOrder = $idx WHERE id = $id")
idx++
}
cursor.close()
if (alsoDeleteStations) {
db.execSQL("DELETE FROM stations WHERE playlistId IN (SELECT id FROM playlists WHERE isBuiltIn = 0)")
db.execSQL("DELETE FROM stations WHERE playlistId IS NULL")
db.execSQL("DELETE FROM playlists WHERE isBuiltIn = 0")
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
withContext(Dispatchers.Main) {
Toast.makeText(app, "Reset complete", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(app, "Reset failed: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
fun restartApp() {
_isRestarting.value = true
viewModelScope.launch {
app.controller.stop()
var waited = 0L
while (waited < 3000L && app.controller.state.value !is PlaybackState.Idle) {
delay(200)
waited += 200
}
val intent = Intent.makeRestartActivityTask(
ComponentName(app, MainActivity::class.java)
)
app.startActivity(intent)
Runtime.getRuntime().exit(0)
}
}
fun parseQualityOrder(json: String): List<String> {
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) {
StreamResolver.DEFAULT_ORDER
}
}
}

View File

@@ -1,14 +1,10 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -19,19 +15,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import xyz.cottongin.radio247.data.model.Playlist
@Composable
fun AddStationDialog(
playlists: List<Playlist>,
onDismiss: () -> Unit,
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit,
onConfirm: (name: String, url: String) -> Unit,
modifier: Modifier = Modifier
) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var selectedPlaylistId by remember { mutableStateOf<Long?>(null) }
var playlistMenuExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -53,44 +45,13 @@ fun AddStationDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) {
OutlinedTextField(
value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist",
onValueChange = {},
readOnly = true,
label = { Text("Playlist") },
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = playlistMenuExpanded,
onDismissRequest = { playlistMenuExpanded = false }
) {
DropdownMenuItem(
text = { Text("No playlist") },
onClick = {
selectedPlaylistId = null
playlistMenuExpanded = false
}
)
for (playlist in playlists) {
DropdownMenuItem(
text = { Text(playlist.name) },
onClick = {
selectedPlaylistId = playlist.id
playlistMenuExpanded = false
}
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim(), selectedPlaylistId)
onConfirm(name.trim(), url.trim())
onDismiss()
}
}

View File

@@ -1,14 +1,10 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -19,21 +15,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
@Composable
fun EditStationDialog(
station: Station,
playlists: List<Playlist>,
onDismiss: () -> Unit,
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit,
onConfirm: (name: String, url: String) -> Unit,
modifier: Modifier = Modifier
) {
var name by remember(station.id) { mutableStateOf(station.name) }
var url by remember(station.id) { mutableStateOf(station.url) }
var selectedPlaylistId by remember(station.id) { mutableStateOf(station.playlistId) }
var playlistMenuExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -55,44 +47,13 @@ fun EditStationDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) {
OutlinedTextField(
value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist",
onValueChange = {},
readOnly = true,
label = { Text("Playlist") },
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = playlistMenuExpanded,
onDismissRequest = { playlistMenuExpanded = false }
) {
DropdownMenuItem(
text = { Text("No playlist") },
onClick = {
selectedPlaylistId = null
playlistMenuExpanded = false
}
)
for (playlist in playlists) {
DropdownMenuItem(
text = { Text(playlist.name) },
onClick = {
selectedPlaylistId = playlist.id
playlistMenuExpanded = false
}
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim(), selectedPlaylistId)
onConfirm(name.trim(), url.trim())
onDismiss()
}
}

View File

@@ -3,16 +3,17 @@ package xyz.cottongin.radio247.ui.screens.stationlist
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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
@@ -21,23 +22,31 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.lazy.rememberLazyListState
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
@@ -45,12 +54,13 @@ 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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.service.PlaybackState
import xyz.cottongin.radio247.ui.components.MiniPlayer
@@ -62,17 +72,17 @@ fun StationListScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: StationListViewModel = viewModel(
factory = StationListViewModelFactory(LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication)
factory = StationListViewModelFactory(
LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication
)
)
) {
val viewState by viewModel.viewState.collectAsState()
val playbackState by viewModel.playbackState.collectAsState()
val playlists = viewState.playlistsWithStations.map { it.first }
var showAddStation by remember { mutableStateOf(false) }
var showAddPlaylist by remember { mutableStateOf(false) }
var stationToEdit by remember { mutableStateOf<Station?>(null) }
var expandedPlaylistIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
@@ -81,6 +91,9 @@ fun StationListScreen(
}
)
val currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex)
val isBuiltInTab = currentTab?.isBuiltIn == true
Scaffold(
modifier = modifier,
topBar = {
@@ -91,13 +104,10 @@ fun StationListScreen(
titleContentColor = MaterialTheme.colorScheme.onSurface
),
actions = {
if (!isBuiltInTab) {
IconButton(onClick = {
importLauncher.launch(
arrayOf(
"audio/*",
"application/x-mpegurl",
"*/*"
)
arrayOf("audio/*", "application/x-mpegurl", "*/*")
)
}) {
Icon(Icons.Default.Upload, contentDescription = "Import")
@@ -105,8 +115,11 @@ fun StationListScreen(
IconButton(onClick = { showAddStation = true }) {
Icon(Icons.Default.Add, contentDescription = "Add Station")
}
IconButton(onClick = { showAddPlaylist = true }) {
Icon(Icons.Default.Folder, contentDescription = "Add Playlist")
}
if (currentTab?.playlist != null && !isBuiltInTab) {
IconButton(onClick = { viewModel.deletePlaylist(currentTab.playlist) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Tab")
}
}
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
@@ -136,62 +149,97 @@ fun StationListScreen(
PlaybackState.Idle -> null
}
LazyColumn(
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
if (viewState.unsortedStations.isNotEmpty()) {
item(key = "unsorted_header") {
SectionHeader("Unsorted")
}
items(
items = viewState.unsortedStations,
key = { it.id }
) { station ->
StationRow(
station = station,
isNowPlaying = station.id == currentPlayingStationId,
onPlay = { viewModel.playStation(station) },
onToggleStar = { viewModel.toggleStar(station) },
onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) }
if (viewState.tabs.size > 1) {
ScrollableTabRow(
selectedTabIndex = viewState.selectedTabIndex,
edgePadding = 16.dp
) {
viewState.tabs.forEachIndexed { index, tab ->
Tab(
selected = viewState.selectedTabIndex == index,
onClick = { viewModel.selectTab(index) },
text = {
Text(
text = tab.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
for ((playlist, stations) in viewState.playlistsWithStations) {
val isExpanded = playlist.id in expandedPlaylistIds
item(key = "playlist_header_${playlist.id}") {
PlaylistSectionHeader(
playlist = playlist,
stationCount = stations.size,
isExpanded = isExpanded,
onToggleExpand = {
expandedPlaylistIds = if (isExpanded) {
expandedPlaylistIds - playlist.id
} else {
expandedPlaylistIds + playlist.id
SortChipRow(
sortMode = viewState.sortMode,
isBuiltInTab = isBuiltInTab,
isPolling = viewState.isPollingListeners,
hiddenCount = viewState.hiddenCount,
showHidden = viewState.showHidden,
onSortChanged = { viewModel.setSortMode(it) },
onRefreshListeners = { viewModel.refreshListenerCounts() },
onToggleShowHidden = { viewModel.toggleShowHidden() }
)
val listState = rememberLazyListState()
LaunchedEffect(viewState.sortMode, viewState.selectedTabIndex, viewState.showHidden) {
listState.scrollToItem(0)
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
if (viewState.stations.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Text(
text = when {
viewState.showHidden -> "No hidden stations"
currentTab?.playlist == null -> "No stations yet"
else -> "No stations in this playlist"
},
onToggleStar = { viewModel.togglePlaylistStar(playlist) }
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isExpanded) {
}
}
items(
items = stations,
items = viewState.stations,
key = { it.id }
) { station ->
StationRow(
station = station,
isNowPlaying = station.id == currentPlayingStationId,
showListeners = isBuiltInTab,
isBuiltIn = isBuiltInTab,
isHiddenView = viewState.showHidden,
onPlay = { viewModel.playStation(station) },
onToggleStar = { viewModel.toggleStar(station) },
onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) }
onDelete = { viewModel.deleteStation(station) },
onToggleHidden = { viewModel.toggleHidden(station) }
)
}
if (currentTab?.playlist == null && viewState.stations.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -199,10 +247,9 @@ fun StationListScreen(
if (showAddStation) {
AddStationDialog(
playlists = playlists,
onDismiss = { showAddStation = false },
onConfirm = { name, url, playlistId ->
viewModel.addStation(name, url, playlistId)
onConfirm = { name, url ->
viewModel.addStation(name, url)
showAddStation = false
}
)
@@ -221,10 +268,9 @@ fun StationListScreen(
stationToEdit?.let { station ->
EditStationDialog(
station = station,
playlists = playlists,
onDismiss = { stationToEdit = null },
onConfirm = { name, url, playlistId ->
viewModel.updateStation(station, name, url, playlistId)
onConfirm = { name, url ->
viewModel.updateStation(station, name, url)
stationToEdit = null
}
)
@@ -232,55 +278,67 @@ fun StationListScreen(
}
@Composable
private fun SectionHeader(
title: String,
modifier: Modifier = Modifier
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun PlaylistSectionHeader(
playlist: Playlist,
stationCount: Int,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
onToggleStar: () -> Unit,
private fun SortChipRow(
sortMode: SortMode,
isBuiltInTab: Boolean,
isPolling: Boolean,
hiddenCount: Int,
showHidden: Boolean,
onSortChanged: (SortMode) -> Unit,
onRefreshListeners: () -> Unit,
onToggleShowHidden: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpand)
.padding(vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(24.dp)
FilterChip(
selected = sortMode == SortMode.DEFAULT,
onClick = { onSortChanged(SortMode.DEFAULT) },
label = { Text("Default") }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${playlist.name} ($stationCount)",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
FilterChip(
selected = sortMode == SortMode.NAME_ASC,
onClick = { onSortChanged(SortMode.NAME_ASC) },
label = { Text("A-Z") }
)
FilterChip(
selected = sortMode == SortMode.NAME_DESC,
onClick = { onSortChanged(SortMode.NAME_DESC) },
label = { Text("Z-A") }
)
if (isBuiltInTab) {
IconButton(
onClick = { onToggleStar() },
onClick = onRefreshListeners,
enabled = !isPolling,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (playlist.starred) "Unstar" else "Star",
tint = if (playlist.starred) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
if (isPolling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh listener counts",
modifier = Modifier.size(20.dp)
)
}
}
}
if (hiddenCount > 0) {
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onToggleShowHidden) {
Text(
text = if (showHidden) "Show visible" else "Show $hiddenCount hidden",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
@@ -290,18 +348,24 @@ private fun PlaylistSectionHeader(
private fun StationRow(
station: Station,
isNowPlaying: Boolean,
showListeners: Boolean,
isBuiltIn: Boolean,
isHiddenView: Boolean,
onPlay: () -> Unit,
onToggleStar: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onToggleHidden: () -> Unit,
modifier: Modifier = Modifier
) {
var showMenu by remember { mutableStateOf(false) }
val rowAlpha = if (isHiddenView) 0.5f else 1f
Box(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(rowAlpha)
.combinedClickable(
onClick = onPlay,
onLongClick = { showMenu = true }
@@ -310,7 +374,7 @@ private fun StationRow(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { onToggleStar() },
onClick = onToggleStar,
modifier = Modifier.size(36.dp)
) {
Icon(
@@ -335,7 +399,9 @@ private fun StationRow(
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isNowPlaying) {
Text(
@@ -345,6 +411,14 @@ private fun StationRow(
)
}
}
if (showListeners && station.listenerCount > 0) {
Text(
text = "${station.listenerCount}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
}
if (isNowPlaying) {
Icon(
Icons.Default.PlayArrow,
@@ -359,6 +433,24 @@ private fun StationRow(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (isHiddenView) {
DropdownMenuItem(
text = { Text("Unhide") },
onClick = {
showMenu = false
onToggleHidden()
}
)
} else {
DropdownMenuItem(
text = { Text("Hide") },
onClick = {
showMenu = false
onToggleHidden()
}
)
}
if (!isBuiltIn && !isHiddenView) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
@@ -376,3 +468,4 @@ private fun StationRow(
}
}
}
}

View File

@@ -5,25 +5,43 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.db.PlaylistDao
import xyz.cottongin.radio247.data.db.StationDao
import xyz.cottongin.radio247.data.api.SomaFmApi
import xyz.cottongin.radio247.data.importing.M3uParser
import xyz.cottongin.radio247.data.importing.PlsParser
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
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.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class StationListViewState(
val unsortedStations: List<Station>,
val playlistsWithStations: List<Pair<Playlist, List<Station>>>
data class TabInfo(
val playlist: Playlist?,
val label: String,
val isBuiltIn: Boolean
)
enum class SortMode { DEFAULT, NAME_ASC, NAME_DESC }
data class StationListViewState(
val tabs: List<TabInfo> = emptyList(),
val selectedTabIndex: Int = 0,
val sortMode: SortMode = SortMode.DEFAULT,
val stations: List<Station> = emptyList(),
val isPollingListeners: Boolean = false,
val hiddenCount: Int = 0,
val showHidden: Boolean = false
)
@OptIn(ExperimentalCoroutinesApi::class)
class StationListViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication
private val stationDao = app.database.stationDao()
@@ -32,22 +50,154 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
val playbackState = controller.state
val viewState = playlistDao.getAllPlaylists().flatMapLatest { playlists ->
val stationFlows = playlists.map { stationDao.getStationsByPlaylist(it.id) }
combine(
flowOf(playlists),
stationDao.getUnsortedStations(),
*stationFlows.toTypedArray()
) { array ->
val pl = array[0] as List<Playlist>
val unsorted = array[1] as List<Station>
val stationLists = array.drop(2).map { it as List<Station> }
StationListViewState(
unsortedStations = unsorted,
playlistsWithStations = pl.zip(stationLists) { p, s -> p to s }
)
private val _selectedTabIndex = MutableStateFlow(0)
private val _sortMode = MutableStateFlow(SortMode.DEFAULT)
val sortMode: StateFlow<SortMode> = _sortMode.asStateFlow()
private val _isPollingListeners = MutableStateFlow(false)
private val _showHidden = MutableStateFlow(false)
private var pollingJob: Job? = null
private val playlistsFlow = playlistDao.getAllPlaylists()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val currentStationsFlow = combine(
_selectedTabIndex, playlistsFlow, _showHidden
) { tabIndex, playlists, showHidden ->
Triple(tabIndex, playlists, showHidden)
}.flatMapLatest { (tabIndex, playlists, showHidden) ->
val tabs = buildTabs(playlists)
val playlist = if (tabIndex < tabs.size) tabs[tabIndex].playlist else null
if (showHidden) {
if (playlist == null) stationDao.getHiddenUnsortedStations()
else stationDao.getHiddenStationsByPlaylist(playlist.id)
} else {
if (playlist == null) stationDao.getUnsortedStations()
else stationDao.getStationsByPlaylist(playlist.id)
}
}
private val hiddenCountFlow = combine(
_selectedTabIndex, playlistsFlow
) { tabIndex, playlists ->
val tabs = buildTabs(playlists)
if (tabIndex < tabs.size) tabs[tabIndex].playlist else null
}.flatMapLatest { playlist ->
if (playlist == null) stationDao.getHiddenUnsortedCount()
else stationDao.getHiddenCountByPlaylist(playlist.id)
}
val viewState: StateFlow<StationListViewState> = combine(
playlistsFlow, _selectedTabIndex, _sortMode, currentStationsFlow,
_isPollingListeners, hiddenCountFlow, _showHidden
) { values ->
@Suppress("UNCHECKED_CAST")
val playlists = values[0] as List<Playlist>
val tabIndex = values[1] as Int
val sortMode = values[2] as SortMode
@Suppress("UNCHECKED_CAST")
val stations = values[3] as List<Station>
val isPolling = values[4] as Boolean
val hiddenCount = values[5] as Int
val showHidden = values[6] as Boolean
val tabs = buildTabs(playlists)
val safeIndex = tabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
val currentTabIsBuiltIn = tabs.getOrNull(safeIndex)?.isBuiltIn == true
val sorted = applySortMode(stations, sortMode, currentTabIsBuiltIn)
StationListViewState(
tabs = tabs,
selectedTabIndex = safeIndex,
sortMode = sortMode,
stations = sorted,
isPollingListeners = isPolling,
hiddenCount = hiddenCount,
showHidden = showHidden
)
}.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState())
private fun buildTabs(playlists: List<Playlist>): List<TabInfo> {
val myStations = TabInfo(playlist = null, label = "My Stations", isBuiltIn = false)
val playlistTabs = playlists
.sortedWith(compareByDescending<Playlist> { it.isBuiltIn }.thenBy { it.sortOrder })
.map { TabInfo(playlist = it, label = it.name, isBuiltIn = it.isBuiltIn) }
return listOf(myStations) + playlistTabs
}
private fun applySortMode(
stations: List<Station>,
sortMode: SortMode,
isBuiltInTab: Boolean
): List<Station> {
val starred = stations.filter { it.starred }
val unstarred = stations.filter { !it.starred }
fun List<Station>.sorted(): List<Station> = when (sortMode) {
SortMode.DEFAULT -> if (isBuiltInTab) sortedByDescending { it.listenerCount } else this
SortMode.NAME_ASC -> sortedBy { it.name.lowercase() }
SortMode.NAME_DESC -> sortedByDescending { it.name.lowercase() }
}
return starred.sorted() + unstarred.sorted()
}
fun selectTab(index: Int) {
_selectedTabIndex.value = index
_sortMode.value = SortMode.DEFAULT
_showHidden.value = false
pollingJob?.cancel()
_isPollingListeners.value = false
}
fun setSortMode(mode: SortMode) {
_sortMode.value = mode
}
fun refreshListenerCounts() {
pollListenerCounts()
}
private fun pollListenerCounts() {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
_isPollingListeners.value = true
try {
val counts = SomaFmApi.fetchListenerCounts(app.okHttpClient)
if (counts.isEmpty()) return@launch
val stations = currentStationsFlow
.stateIn(viewModelScope)
.value
val starred = stations.filter { it.starred }
val unstarred = stations.filter { !it.starred }
for (station in starred) {
val streamId = SomaFmApi.extractStreamId(station.url) ?: continue
val count = counts[streamId] ?: continue
stationDao.updateListenerCount(station.id, count)
}
for (station in unstarred) {
val streamId = SomaFmApi.extractStreamId(station.url) ?: continue
val count = counts[streamId] ?: continue
stationDao.updateListenerCount(station.id, count)
}
} finally {
_isPollingListeners.value = false
}
}
}
fun toggleHidden(station: Station) {
viewModelScope.launch {
stationDao.toggleHidden(station.id, !station.isHidden)
}
}
fun toggleShowHidden() {
_showHidden.value = !_showHidden.value
}
}.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState(emptyList(), emptyList()))
fun playStation(station: Station) {
viewModelScope.launch {
@@ -60,25 +210,21 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch { stationDao.toggleStarred(station.id, !station.starred) }
}
fun togglePlaylistStar(playlist: Playlist) {
viewModelScope.launch { playlistDao.toggleStarred(playlist.id, !playlist.starred) }
}
fun deleteStation(station: Station) {
viewModelScope.launch { stationDao.delete(station) }
}
fun addStation(name: String, url: String, playlistId: Long?) {
fun addStation(name: String, url: String) {
val currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex)
val playlistId = currentTab?.playlist?.id
viewModelScope.launch {
stationDao.insert(Station(name = name, url = url, playlistId = playlistId))
}
}
fun updateStation(station: Station, name: String, url: String, playlistId: Long?) {
fun updateStation(station: Station, name: String, url: String) {
viewModelScope.launch {
stationDao.update(
station.copy(name = name, url = url, playlistId = playlistId)
)
stationDao.update(station.copy(name = name, url = url))
}
}
@@ -88,22 +234,50 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
}
}
fun deletePlaylist(playlist: Playlist) {
if (playlist.isBuiltIn) return
viewModelScope.launch {
playlistDao.delete(playlist)
if (_selectedTabIndex.value > 0) {
_selectedTabIndex.value = 0
}
}
}
fun importFile(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: return@launch
val fileName = uri.lastPathSegment
?.substringAfterLast('/')
?.substringBeforeLast('.')
?: "Imported"
val isM3u = content.trimStart().startsWith("#EXTM3U") ||
uri.toString().endsWith(".m3u", ignoreCase = true)
val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content)
for (station in parsed) {
if (parsed.isEmpty()) return@launch
val playlistId = playlistDao.insert(Playlist(name = fileName))
for ((index, station) in parsed.withIndex()) {
stationDao.insert(
Station(
name = station.name,
url = station.url,
playlistId = playlistId,
sortOrder = index,
defaultArtworkUrl = station.artworkUrl
)
)
}
withContext(Dispatchers.Main) {
val playlists = playlistsFlow.value
val tabs = buildTabs(playlists)
val newTabIndex = tabs.indexOfFirst { it.playlist?.id == playlistId }
if (newTabIndex >= 0) {
_selectedTabIndex.value = newTabIndex
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ junit = "4.13.2"
mockk = "1.13.16"
turbine = "1.2.0"
coil = "3.1.0"
cloudy = "0.2.7"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@@ -46,6 +47,8 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
json = { group = "org.json", name = "json", version = "20240303" }
cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" }
palette = { group = "androidx.palette", name = "palette-ktx", version = "1.0.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }