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.material)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network) implementation(libs.coil.network)
implementation(libs.cloudy)
implementation(libs.palette)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk) 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 android.app.Application
import androidx.room.Room import androidx.room.Room
import xyz.cottongin.radio247.data.db.RadioDatabase 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.data.prefs.RadioPreferences
import xyz.cottongin.radio247.metadata.AlbumArtResolver import xyz.cottongin.radio247.metadata.AlbumArtResolver
import xyz.cottongin.radio247.service.RadioController import xyz.cottongin.radio247.service.RadioController
import xyz.cottongin.radio247.service.StreamResolver
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class RadioApplication : Application() { class RadioApplication : Application() {
val database: RadioDatabase by lazy { val database: RadioDatabase by lazy {
Room.databaseBuilder(this, RadioDatabase::class.java, "radio_database") Room.databaseBuilder(this, RadioDatabase::class.java, "radio_database")
.addCallback(SomaFmSeedData)
.addMigrations(RadioDatabase.MIGRATION_1_2, RadioDatabase.MIGRATION_2_3, RadioDatabase.MIGRATION_3_4)
.build() .build()
} }
@@ -29,4 +34,12 @@ class RadioApplication : Application() {
val albumArtResolver: AlbumArtResolver by lazy { val albumArtResolver: AlbumArtResolver by lazy {
AlbumArtResolver(okHttpClient) 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.Database
import androidx.room.RoomDatabase 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.ConnectionSpan
import xyz.cottongin.radio247.data.model.ListeningSession import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Playlist import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream
@Database( @Database(
entities = [ entities = [
@@ -14,9 +17,10 @@ import xyz.cottongin.radio247.data.model.Station
Playlist::class, Playlist::class,
MetadataSnapshot::class, MetadataSnapshot::class,
ListeningSession::class, ListeningSession::class,
ConnectionSpan::class ConnectionSpan::class,
StationStream::class
], ],
version = 1, version = 4,
exportSchema = true exportSchema = true
) )
abstract class RadioDatabase : RoomDatabase() { abstract class RadioDatabase : RoomDatabase() {
@@ -25,4 +29,39 @@ abstract class RadioDatabase : RoomDatabase() {
abstract fun metadataSnapshotDao(): MetadataSnapshotDao abstract fun metadataSnapshotDao(): MetadataSnapshotDao
abstract fun listeningSessionDao(): ListeningSessionDao abstract fun listeningSessionDao(): ListeningSessionDao
abstract fun connectionSpanDao(): ConnectionSpanDao 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") @Query("SELECT * FROM stations ORDER BY starred DESC, sortOrder ASC")
fun getAllStations(): Flow<List<Station>> 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>> 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>> 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") @Query("SELECT * FROM stations WHERE id = :id")
suspend fun getStationById(id: Long): Station? suspend fun getStationById(id: Long): Station?
@@ -36,4 +48,10 @@ interface StationDao {
@Query("UPDATE stations SET starred = :starred WHERE id = :id") @Query("UPDATE stations SET starred = :starred WHERE id = :id")
suspend fun toggleStarred(id: Long, starred: Boolean) 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, @PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String, val name: String,
val sortOrder: Int = 0, 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 playlistId: Long? = null,
val sortOrder: Int = 0, val sortOrder: Int = 0,
val starred: Boolean = false, 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.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map 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 bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] } 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) { suspend fun setStayConnected(value: Boolean) {
dataStore.edit { it[STAY_CONNECTED] = value } dataStore.edit { it[STAY_CONNECTED] = value }
} }
@@ -28,10 +36,32 @@ class RadioPreferences(private val context: Context) {
dataStore.edit { it[LAST_STATION_ID] = value } 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 { companion object {
private val Context.dataStore by preferencesDataStore(name = "radio_prefs") private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected") private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
private val BUFFER_MS = intPreferencesKey("buffer_ms") private val BUFFER_MS = intPreferencesKey("buffer_ms")
private val LAST_STATION_ID = longPreferencesKey("last_station_id") 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.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -40,11 +47,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import xyz.cottongin.radio247.data.model.ListeningSession import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -59,26 +66,43 @@ fun SettingsScreen(
) { ) {
val stayConnected by viewModel.stayConnected.collectAsState() val stayConnected by viewModel.stayConnected.collectAsState()
val bufferMs by viewModel.bufferMs.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 recentSessions by viewModel.recentSessions.collectAsState()
val filteredTracks by viewModel.filteredTracks.collectAsState() val filteredTracks by viewModel.filteredTracks.collectAsState()
val stations by viewModel.stations.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 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( val createDocumentM3u = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl")
) { uri: Uri? -> ) { uri: Uri? ->
uri?.let { uri?.let { viewModel.exportPlaylist(null, stations, "m3u", it) }
viewModel.exportPlaylist(null, stations, "m3u", it)
}
} }
val createDocumentPls = rememberLauncherForActivityResult( val createDocumentPls = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-scpls") 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: Uri? ->
uri?.let { 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") }, title = { Text("Settings") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -106,22 +127,12 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// ── PLAYBACK ──
SectionHeader("PLAYBACK") SectionHeader("PLAYBACK")
Row( SettingRow("Stay Connected") {
modifier = Modifier.fillMaxWidth(), Switch(checked = stayConnected, onCheckedChange = { viewModel.setStayConnected(it) })
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "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( Slider(
value = bufferMs.toFloat(), value = bufferMs.toFloat(),
onValueChange = { viewModel.setBufferMs(it.toInt()) }, onValueChange = { viewModel.setBufferMs(it.toInt()) },
@@ -136,16 +147,70 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// ── 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 = { pickHistoryDir.launch(null) },
modifier = Modifier.fillMaxWidth()
) {
Text("Choose Directory")
}
}
Spacer(modifier = Modifier.height(24.dp))
// ── EXPORT ──
SectionHeader("EXPORT") SectionHeader("EXPORT")
Button( Button(onClick = { showExportDialog = true }, modifier = Modifier.fillMaxWidth()) {
onClick = { showExportDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Export Playlist") Text("Export Playlist")
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// ── RECENTLY PLAYED ──
SectionHeader("RECENTLY PLAYED") SectionHeader("RECENTLY PLAYED")
val stationMap = stations.associateBy { it.id } val stationMap = stations.associateBy { it.id }
LazyColumn( LazyColumn(
@@ -162,6 +227,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// ── TRACK HISTORY ──
SectionHeader("TRACK HISTORY") SectionHeader("TRACK HISTORY")
OutlinedTextField( OutlinedTextField(
value = trackHistoryQuery, 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) { if (showExportDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showExportDialog = false }, onDismissRequest = { showExportDialog = false },
confirmButton = { confirmButton = {
Button(onClick = { showExportDialog = false }) { Button(onClick = { showExportDialog = false }) { Text("Cancel") }
Text("Cancel")
}
}, },
title = { Text("Export format") }, title = { Text("Export format") },
text = { text = {
@@ -205,9 +313,7 @@ fun SettingsScreen(
createDocumentM3u.launch("playlist.m3u") createDocumentM3u.launch("playlist.m3u")
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) { Text("M3U") }
Text("M3U")
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( Button(
onClick = { onClick = {
@@ -215,20 +321,118 @@ fun SettingsScreen(
createDocumentPls.launch("playlist.pls") createDocumentPls.launch("playlist.pls")
}, },
modifier = Modifier.fillMaxWidth() 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 @Composable
private fun SectionHeader( private fun SettingRow(
title: String, label: String,
modifier: Modifier = Modifier 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(
text = title, text = title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -283,10 +487,7 @@ private fun TrackHistoryRow(
val timestamp = formatRelativeTime(snapshot.timestamp) val timestamp = formatRelativeTime(snapshot.timestamp)
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
Text( Text(text = trackInfo, style = MaterialTheme.typography.bodyMedium)
text = trackInfo,
style = MaterialTheme.typography.bodyMedium
)
Text( Text(
text = "$stationName | $timestamp", text = "$stationName | $timestamp",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -1,35 +1,53 @@
package xyz.cottongin.radio247.ui.screens.settings package xyz.cottongin.radio247.ui.screens.settings
import android.app.Application import android.app.Application
import kotlinx.coroutines.ExperimentalCoroutinesApi import android.content.ComponentName
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.MainActivity
import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.importing.PlaylistExporter import xyz.cottongin.radio247.data.importing.PlaylistExporter
import xyz.cottongin.radio247.data.model.MetadataSnapshot import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModel(application: Application) : AndroidViewModel(application) { class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication private val app = application as RadioApplication
val stayConnected = app.preferences.stayConnected.stateIn( val stayConnected = app.preferences.stayConnected.stateIn(
viewModelScope, viewModelScope, SharingStarted.Lazily, false
SharingStarted.Lazily,
false
) )
val bufferMs = app.preferences.bufferMs.stateIn( val bufferMs = app.preferences.bufferMs.stateIn(
viewModelScope, viewModelScope, SharingStarted.Lazily, 0
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() val recentSessions = app.database.listeningSessionDao()
@@ -51,16 +69,32 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.getAllStations() .getAllStations()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val _isRestarting = MutableStateFlow(false)
val isRestarting: StateFlow<Boolean> = _isRestarting
fun setStayConnected(value: Boolean) { fun setStayConnected(value: Boolean) {
viewModelScope.launch { viewModelScope.launch { app.preferences.setStayConnected(value) }
app.preferences.setStayConnected(value)
}
} }
fun setBufferMs(value: Int) { fun setBufferMs(value: Int) {
viewModelScope.launch { viewModelScope.launch { app.preferences.setBufferMs(value) }
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( fun exportPlaylist(
@@ -84,4 +118,69 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setTrackHistoryQuery(query: String) { fun setTrackHistoryQuery(query: String) {
trackHistory.value = query 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 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -19,19 +15,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import xyz.cottongin.radio247.data.model.Playlist
@Composable @Composable
fun AddStationDialog( fun AddStationDialog(
playlists: List<Playlist>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit, onConfirm: (name: String, url: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
var selectedPlaylistId by remember { mutableStateOf<Long?>(null) }
var playlistMenuExpanded by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -53,44 +45,13 @@ fun AddStationDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true 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 = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
if (name.isNotBlank() && url.isNotBlank()) { if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim(), selectedPlaylistId) onConfirm(name.trim(), url.trim())
onDismiss() onDismiss()
} }
} }

View File

@@ -1,14 +1,10 @@
package xyz.cottongin.radio247.ui.screens.stationlist 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -19,21 +15,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
@Composable @Composable
fun EditStationDialog( fun EditStationDialog(
station: Station, station: Station,
playlists: List<Playlist>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit, onConfirm: (name: String, url: String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var name by remember(station.id) { mutableStateOf(station.name) } var name by remember(station.id) { mutableStateOf(station.name) }
var url by remember(station.id) { mutableStateOf(station.url) } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -55,44 +47,13 @@ fun EditStationDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true 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 = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
if (name.isNotBlank() && url.isNotBlank()) { if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim(), selectedPlaylistId) onConfirm(name.trim(), url.trim())
onDismiss() onDismiss()
} }
} }

View File

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

View File

@@ -5,25 +5,43 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.db.PlaylistDao import xyz.cottongin.radio247.data.api.SomaFmApi
import xyz.cottongin.radio247.data.db.StationDao
import xyz.cottongin.radio247.data.importing.M3uParser import xyz.cottongin.radio247.data.importing.M3uParser
import xyz.cottongin.radio247.data.importing.PlsParser import xyz.cottongin.radio247.data.importing.PlsParser
import xyz.cottongin.radio247.data.model.Playlist import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.Dispatchers 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class StationListViewState( data class TabInfo(
val unsortedStations: List<Station>, val playlist: Playlist?,
val playlistsWithStations: List<Pair<Playlist, List<Station>>> 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) { class StationListViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication private val app = application as RadioApplication
private val stationDao = app.database.stationDao() private val stationDao = app.database.stationDao()
@@ -32,22 +50,154 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
val playbackState = controller.state val playbackState = controller.state
val viewState = playlistDao.getAllPlaylists().flatMapLatest { playlists -> private val _selectedTabIndex = MutableStateFlow(0)
val stationFlows = playlists.map { stationDao.getStationsByPlaylist(it.id) } private val _sortMode = MutableStateFlow(SortMode.DEFAULT)
combine( val sortMode: StateFlow<SortMode> = _sortMode.asStateFlow()
flowOf(playlists),
stationDao.getUnsortedStations(), private val _isPollingListeners = MutableStateFlow(false)
*stationFlows.toTypedArray() private val _showHidden = MutableStateFlow(false)
) { array -> private var pollingJob: Job? = null
val pl = array[0] as List<Playlist>
val unsorted = array[1] as List<Station> private val playlistsFlow = playlistDao.getAllPlaylists()
val stationLists = array.drop(2).map { it as List<Station> } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
StationListViewState(
unsortedStations = unsorted, private val currentStationsFlow = combine(
playlistsWithStations = pl.zip(stationLists) { p, s -> p to s } _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)
} }
}.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState(emptyList(), emptyList())) }
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
}
fun playStation(station: Station) { fun playStation(station: Station) {
viewModelScope.launch { viewModelScope.launch {
@@ -60,25 +210,21 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch { stationDao.toggleStarred(station.id, !station.starred) } viewModelScope.launch { stationDao.toggleStarred(station.id, !station.starred) }
} }
fun togglePlaylistStar(playlist: Playlist) {
viewModelScope.launch { playlistDao.toggleStarred(playlist.id, !playlist.starred) }
}
fun deleteStation(station: Station) { fun deleteStation(station: Station) {
viewModelScope.launch { stationDao.delete(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 { viewModelScope.launch {
stationDao.insert(Station(name = name, url = url, playlistId = playlistId)) 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 { viewModelScope.launch {
stationDao.update( stationDao.update(station.copy(name = name, url = url))
station.copy(name = name, url = url, playlistId = playlistId)
)
} }
} }
@@ -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) { fun importFile(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText() val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: return@launch ?: return@launch
val fileName = uri.lastPathSegment
?.substringAfterLast('/')
?.substringBeforeLast('.')
?: "Imported"
val isM3u = content.trimStart().startsWith("#EXTM3U") || val isM3u = content.trimStart().startsWith("#EXTM3U") ||
uri.toString().endsWith(".m3u", ignoreCase = true) uri.toString().endsWith(".m3u", ignoreCase = true)
val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content) 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( stationDao.insert(
Station( Station(
name = station.name, name = station.name,
url = station.url, url = station.url,
playlistId = playlistId,
sortOrder = index,
defaultArtworkUrl = station.artworkUrl 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" mockk = "1.13.16"
turbine = "1.2.0" turbine = "1.2.0"
coil = "3.1.0" coil = "3.1.0"
cloudy = "0.2.7"
[libraries] [libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 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-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" } coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
json = { group = "org.json", name = "json", version = "20240303" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }