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:
@@ -65,6 +65,8 @@ dependencies {
|
||||
implementation(libs.material)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network)
|
||||
implementation(libs.cloudy)
|
||||
implementation(libs.palette)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
|
||||
333
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/2.json
Normal file
333
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/2.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
339
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/3.json
Normal file
339
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/3.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
410
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/4.json
Normal file
410
app/schemas/xyz.cottongin.radio247.data.db.RadioDatabase/4.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,19 @@ package xyz.cottongin.radio247
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import xyz.cottongin.radio247.data.db.RadioDatabase
|
||||
import xyz.cottongin.radio247.data.db.SomaFmSeedData
|
||||
import xyz.cottongin.radio247.data.logging.NowPlayingHistoryWriter
|
||||
import xyz.cottongin.radio247.data.prefs.RadioPreferences
|
||||
import xyz.cottongin.radio247.metadata.AlbumArtResolver
|
||||
import xyz.cottongin.radio247.service.RadioController
|
||||
import xyz.cottongin.radio247.service.StreamResolver
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class RadioApplication : Application() {
|
||||
val database: RadioDatabase by lazy {
|
||||
Room.databaseBuilder(this, RadioDatabase::class.java, "radio_database")
|
||||
.addCallback(SomaFmSeedData)
|
||||
.addMigrations(RadioDatabase.MIGRATION_1_2, RadioDatabase.MIGRATION_2_3, RadioDatabase.MIGRATION_3_4)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -29,4 +34,12 @@ class RadioApplication : Application() {
|
||||
val albumArtResolver: AlbumArtResolver by lazy {
|
||||
AlbumArtResolver(okHttpClient)
|
||||
}
|
||||
|
||||
val streamResolver: StreamResolver by lazy {
|
||||
StreamResolver(database.stationStreamDao(), preferences)
|
||||
}
|
||||
|
||||
val historyWriter: NowPlayingHistoryWriter by lazy {
|
||||
NowPlayingHistoryWriter(this, preferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package xyz.cottongin.radio247.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import xyz.cottongin.radio247.data.model.ConnectionSpan
|
||||
import xyz.cottongin.radio247.data.model.ListeningSession
|
||||
import xyz.cottongin.radio247.data.model.MetadataSnapshot
|
||||
import xyz.cottongin.radio247.data.model.Playlist
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
import xyz.cottongin.radio247.data.model.StationStream
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -14,9 +17,10 @@ import xyz.cottongin.radio247.data.model.Station
|
||||
Playlist::class,
|
||||
MetadataSnapshot::class,
|
||||
ListeningSession::class,
|
||||
ConnectionSpan::class
|
||||
ConnectionSpan::class,
|
||||
StationStream::class
|
||||
],
|
||||
version = 1,
|
||||
version = 4,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class RadioDatabase : RoomDatabase() {
|
||||
@@ -25,4 +29,39 @@ abstract class RadioDatabase : RoomDatabase() {
|
||||
abstract fun metadataSnapshotDao(): MetadataSnapshotDao
|
||||
abstract fun listeningSessionDao(): ListeningSessionDao
|
||||
abstract fun connectionSpanDao(): ConnectionSpanDao
|
||||
abstract fun stationStreamDao(): StationStreamDao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE stations ADD COLUMN listenerCount INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE playlists ADD COLUMN isBuiltIn INTEGER NOT NULL DEFAULT 0")
|
||||
SomaFmSeedData.seedStations(db, includeIsHidden = false, includeStreams = false)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE stations ADD COLUMN isHidden INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS station_streams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
stationId INTEGER NOT NULL,
|
||||
bitrate INTEGER NOT NULL,
|
||||
ssl INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
FOREIGN KEY(stationId) REFERENCES stations(id) ON DELETE CASCADE
|
||||
)"""
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_station_streams_stationId ON station_streams(stationId)")
|
||||
db.execSQL("ALTER TABLE stations ADD COLUMN qualityOverride TEXT DEFAULT NULL")
|
||||
SomaFmSeedData.seedStreamsForExistingStations(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,24 @@ interface StationDao {
|
||||
@Query("SELECT * FROM stations ORDER BY starred DESC, sortOrder ASC")
|
||||
fun getAllStations(): Flow<List<Station>>
|
||||
|
||||
@Query("SELECT * FROM stations WHERE playlistId = :playlistId ORDER BY starred DESC, sortOrder ASC")
|
||||
@Query("SELECT * FROM stations WHERE playlistId = :playlistId AND isHidden = 0 ORDER BY starred DESC, sortOrder ASC")
|
||||
fun getStationsByPlaylist(playlistId: Long): Flow<List<Station>>
|
||||
|
||||
@Query("SELECT * FROM stations WHERE playlistId IS NULL ORDER BY starred DESC, sortOrder ASC")
|
||||
@Query("SELECT * FROM stations WHERE playlistId IS NULL AND isHidden = 0 ORDER BY starred DESC, sortOrder ASC")
|
||||
fun getUnsortedStations(): Flow<List<Station>>
|
||||
|
||||
@Query("SELECT * FROM stations WHERE playlistId = :playlistId AND isHidden = 1 ORDER BY sortOrder ASC")
|
||||
fun getHiddenStationsByPlaylist(playlistId: Long): Flow<List<Station>>
|
||||
|
||||
@Query("SELECT * FROM stations WHERE playlistId IS NULL AND isHidden = 1 ORDER BY sortOrder ASC")
|
||||
fun getHiddenUnsortedStations(): Flow<List<Station>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM stations WHERE playlistId = :playlistId AND isHidden = 1")
|
||||
fun getHiddenCountByPlaylist(playlistId: Long): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM stations WHERE playlistId IS NULL AND isHidden = 1")
|
||||
fun getHiddenUnsortedCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM stations WHERE id = :id")
|
||||
suspend fun getStationById(id: Long): Station?
|
||||
|
||||
@@ -36,4 +48,10 @@ interface StationDao {
|
||||
|
||||
@Query("UPDATE stations SET starred = :starred WHERE id = :id")
|
||||
suspend fun toggleStarred(id: Long, starred: Boolean)
|
||||
|
||||
@Query("UPDATE stations SET isHidden = :isHidden WHERE id = :id")
|
||||
suspend fun toggleHidden(id: Long, isHidden: Boolean)
|
||||
|
||||
@Query("UPDATE stations SET listenerCount = :count WHERE id = :id")
|
||||
suspend fun updateListenerCount(id: Long, count: Int)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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("\"", "\"\"")
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ data class Playlist(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val sortOrder: Int = 0,
|
||||
val starred: Boolean = false
|
||||
val starred: Boolean = false,
|
||||
val isBuiltIn: Boolean = false
|
||||
)
|
||||
|
||||
@@ -22,5 +22,8 @@ data class Station(
|
||||
val playlistId: Long? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val starred: Boolean = false,
|
||||
val defaultArtworkUrl: String? = null
|
||||
val defaultArtworkUrl: String? = null,
|
||||
val listenerCount: Int = 0,
|
||||
val isHidden: Boolean = false,
|
||||
val qualityOverride: String? = null
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -16,6 +17,13 @@ class RadioPreferences(private val context: Context) {
|
||||
val bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
|
||||
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] }
|
||||
|
||||
val qualityPreference: Flow<String> = dataStore.data.map {
|
||||
it[QUALITY_PREFERENCE] ?: DEFAULT_QUALITY_ORDER
|
||||
}
|
||||
val historyEnabled: Flow<Boolean> = dataStore.data.map { it[HISTORY_ENABLED] ?: false }
|
||||
val historyFormat: Flow<String> = dataStore.data.map { it[HISTORY_FORMAT] ?: "csv" }
|
||||
val historyDirUri: Flow<String> = dataStore.data.map { it[HISTORY_DIR_URI] ?: "" }
|
||||
|
||||
suspend fun setStayConnected(value: Boolean) {
|
||||
dataStore.edit { it[STAY_CONNECTED] = value }
|
||||
}
|
||||
@@ -28,10 +36,32 @@ class RadioPreferences(private val context: Context) {
|
||||
dataStore.edit { it[LAST_STATION_ID] = value }
|
||||
}
|
||||
|
||||
suspend fun setQualityPreference(value: String) {
|
||||
dataStore.edit { it[QUALITY_PREFERENCE] = value }
|
||||
}
|
||||
|
||||
suspend fun setHistoryEnabled(value: Boolean) {
|
||||
dataStore.edit { it[HISTORY_ENABLED] = value }
|
||||
}
|
||||
|
||||
suspend fun setHistoryFormat(value: String) {
|
||||
dataStore.edit { it[HISTORY_FORMAT] = value }
|
||||
}
|
||||
|
||||
suspend fun setHistoryDirUri(value: String) {
|
||||
dataStore.edit { it[HISTORY_DIR_URI] = value }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
|
||||
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
|
||||
private val BUFFER_MS = intPreferencesKey("buffer_ms")
|
||||
private val LAST_STATION_ID = longPreferencesKey("last_station_id")
|
||||
private val QUALITY_PREFERENCE = stringPreferencesKey("quality_preference")
|
||||
private val HISTORY_ENABLED = booleanPreferencesKey("history_enabled")
|
||||
private val HISTORY_FORMAT = stringPreferencesKey("history_format")
|
||||
private val HISTORY_DIR_URI = stringPreferencesKey("history_dir_uri")
|
||||
|
||||
const val DEFAULT_QUALITY_ORDER = """["256-ssl","256-nossl","128-ssl","128-nossl"]"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,29 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -40,11 +47,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import xyz.cottongin.radio247.data.model.ListeningSession
|
||||
import xyz.cottongin.radio247.data.model.MetadataSnapshot
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -59,26 +66,43 @@ fun SettingsScreen(
|
||||
) {
|
||||
val stayConnected by viewModel.stayConnected.collectAsState()
|
||||
val bufferMs by viewModel.bufferMs.collectAsState()
|
||||
val qualityPref by viewModel.qualityPreference.collectAsState()
|
||||
val historyEnabled by viewModel.historyEnabled.collectAsState()
|
||||
val historyFormat by viewModel.historyFormat.collectAsState()
|
||||
val historyDirUri by viewModel.historyDirUri.collectAsState()
|
||||
val recentSessions by viewModel.recentSessions.collectAsState()
|
||||
val filteredTracks by viewModel.filteredTracks.collectAsState()
|
||||
val stations by viewModel.stations.collectAsState()
|
||||
var trackHistoryQuery by remember { mutableStateOf("") }
|
||||
val isRestarting by viewModel.isRestarting.collectAsState()
|
||||
|
||||
var trackHistoryQuery by remember { mutableStateOf("") }
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var resetAlsoDeleteStations by remember { mutableStateOf(false) }
|
||||
|
||||
val qualityOrder = remember(qualityPref) { viewModel.parseQualityOrder(qualityPref) }
|
||||
|
||||
val createDocumentM3u = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl")
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
viewModel.exportPlaylist(null, stations, "m3u", it)
|
||||
}
|
||||
uri?.let { viewModel.exportPlaylist(null, stations, "m3u", it) }
|
||||
}
|
||||
|
||||
val createDocumentPls = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("audio/x-scpls")
|
||||
) { uri: Uri? ->
|
||||
uri?.let { viewModel.exportPlaylist(null, stations, "pls", it) }
|
||||
}
|
||||
|
||||
val pickHistoryDir = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
viewModel.exportPlaylist(null, stations, "pls", it)
|
||||
val context = viewModel.getApplication<xyz.cottongin.radio247.RadioApplication>()
|
||||
val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(it, flags)
|
||||
viewModel.setHistoryDirUri(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +111,7 @@ fun SettingsScreen(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -106,22 +127,12 @@ fun SettingsScreen(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// ── PLAYBACK ──
|
||||
SectionHeader("PLAYBACK")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = "Stay Connected")
|
||||
Switch(
|
||||
checked = stayConnected,
|
||||
onCheckedChange = { viewModel.setStayConnected(it) }
|
||||
)
|
||||
SettingRow("Stay Connected") {
|
||||
Switch(checked = stayConnected, onCheckedChange = { viewModel.setStayConnected(it) })
|
||||
}
|
||||
Text(
|
||||
text = "Buffer: ${bufferMs}ms",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text("Buffer: ${bufferMs}ms", style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = bufferMs.toFloat(),
|
||||
onValueChange = { viewModel.setBufferMs(it.toInt()) },
|
||||
@@ -136,16 +147,70 @@ fun SettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ── 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")
|
||||
Button(
|
||||
onClick = { showExportDialog = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Button(onClick = { showExportDialog = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Export Playlist")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ── RECENTLY PLAYED ──
|
||||
SectionHeader("RECENTLY PLAYED")
|
||||
val stationMap = stations.associateBy { it.id }
|
||||
LazyColumn(
|
||||
@@ -162,6 +227,7 @@ fun SettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ── TRACK HISTORY ──
|
||||
SectionHeader("TRACK HISTORY")
|
||||
OutlinedTextField(
|
||||
value = trackHistoryQuery,
|
||||
@@ -185,16 +251,58 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ── RESET ──
|
||||
SectionHeader("RESET")
|
||||
SettingRow("Also delete saved stations/playlists") {
|
||||
Switch(
|
||||
checked = resetAlsoDeleteStations,
|
||||
onCheckedChange = { resetAlsoDeleteStations = it }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { showResetDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Text("Reset All Customizations")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ── RESTART ──
|
||||
SectionHeader("APP")
|
||||
Button(
|
||||
onClick = { viewModel.restartApp() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isRestarting
|
||||
) {
|
||||
if (isRestarting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Restart App")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── DIALOGS ──
|
||||
|
||||
if (showExportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
confirmButton = {
|
||||
Button(onClick = { showExportDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(onClick = { showExportDialog = false }) { Text("Cancel") }
|
||||
},
|
||||
title = { Text("Export format") },
|
||||
text = {
|
||||
@@ -205,9 +313,7 @@ fun SettingsScreen(
|
||||
createDocumentM3u.launch("playlist.m3u")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("M3U")
|
||||
}
|
||||
) { Text("M3U") }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -215,20 +321,118 @@ fun SettingsScreen(
|
||||
createDocumentPls.launch("playlist.pls")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("PLS")
|
||||
}
|
||||
) { Text("PLS") }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showResetDialog) {
|
||||
val message = if (resetAlsoDeleteStations) {
|
||||
"This will clear all stars, custom sort orders, hidden states, and quality overrides. All user-added stations and playlists will be deleted. This cannot be undone."
|
||||
} else {
|
||||
"This will clear all stars, custom sort orders, hidden states, and quality overrides. Your saved stations will be kept. This cannot be undone."
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
viewModel.resetCustomizations(resetAlsoDeleteStations)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) { Text("Reset") }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { showResetDialog = false }) { Text("Cancel") }
|
||||
},
|
||||
title = { Text("Reset Customizations") },
|
||||
text = { Text(message) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
private fun SettingRow(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
control: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = label, modifier = Modifier.weight(1f))
|
||||
control()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QualityOrderList(
|
||||
order: List<String>,
|
||||
onReorder: (List<String>) -> Unit
|
||||
) {
|
||||
val labels = mapOf(
|
||||
"256-ssl" to "256 kbps (SSL)",
|
||||
"256-nossl" to "256 kbps",
|
||||
"128-ssl" to "128 kbps (SSL)",
|
||||
"128-nossl" to "128 kbps"
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
order.forEachIndexed { index, key ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = labels[key] ?: key,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (index > 0) {
|
||||
val mutable = order.toMutableList()
|
||||
mutable[index] = mutable[index - 1].also { mutable[index - 1] = mutable[index] }
|
||||
onReorder(mutable)
|
||||
}
|
||||
},
|
||||
enabled = index > 0
|
||||
) {
|
||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Move up", modifier = Modifier.size(20.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (index < order.lastIndex) {
|
||||
val mutable = order.toMutableList()
|
||||
mutable[index] = mutable[index + 1].also { mutable[index + 1] = mutable[index] }
|
||||
onReorder(mutable)
|
||||
}
|
||||
},
|
||||
enabled = index < order.lastIndex
|
||||
) {
|
||||
Icon(Icons.Default.ArrowDownward, contentDescription = "Move down", modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
@@ -283,10 +487,7 @@ private fun TrackHistoryRow(
|
||||
val timestamp = formatRelativeTime(snapshot.timestamp)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = trackInfo,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(text = trackInfo, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = "$stationName | $timestamp",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
package xyz.cottongin.radio247.ui.screens.settings
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import xyz.cottongin.radio247.MainActivity
|
||||
import xyz.cottongin.radio247.RadioApplication
|
||||
import xyz.cottongin.radio247.data.importing.PlaylistExporter
|
||||
import xyz.cottongin.radio247.data.model.MetadataSnapshot
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
import xyz.cottongin.radio247.data.prefs.RadioPreferences
|
||||
import xyz.cottongin.radio247.service.PlaybackState
|
||||
import xyz.cottongin.radio247.service.StreamResolver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val app = application as RadioApplication
|
||||
|
||||
val stayConnected = app.preferences.stayConnected.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Lazily,
|
||||
false
|
||||
viewModelScope, SharingStarted.Lazily, false
|
||||
)
|
||||
val bufferMs = app.preferences.bufferMs.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Lazily,
|
||||
0
|
||||
viewModelScope, SharingStarted.Lazily, 0
|
||||
)
|
||||
val qualityPreference = app.preferences.qualityPreference.stateIn(
|
||||
viewModelScope, SharingStarted.Lazily, RadioPreferences.DEFAULT_QUALITY_ORDER
|
||||
)
|
||||
val historyEnabled = app.preferences.historyEnabled.stateIn(
|
||||
viewModelScope, SharingStarted.Lazily, false
|
||||
)
|
||||
val historyFormat = app.preferences.historyFormat.stateIn(
|
||||
viewModelScope, SharingStarted.Lazily, "csv"
|
||||
)
|
||||
val historyDirUri = app.preferences.historyDirUri.stateIn(
|
||||
viewModelScope, SharingStarted.Lazily, ""
|
||||
)
|
||||
|
||||
val recentSessions = app.database.listeningSessionDao()
|
||||
@@ -51,16 +69,32 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
.getAllStations()
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
private val _isRestarting = MutableStateFlow(false)
|
||||
val isRestarting: StateFlow<Boolean> = _isRestarting
|
||||
|
||||
fun setStayConnected(value: Boolean) {
|
||||
viewModelScope.launch {
|
||||
app.preferences.setStayConnected(value)
|
||||
}
|
||||
viewModelScope.launch { app.preferences.setStayConnected(value) }
|
||||
}
|
||||
|
||||
fun setBufferMs(value: Int) {
|
||||
viewModelScope.launch {
|
||||
app.preferences.setBufferMs(value)
|
||||
}
|
||||
viewModelScope.launch { app.preferences.setBufferMs(value) }
|
||||
}
|
||||
|
||||
fun setQualityPreference(orderedKeys: List<String>) {
|
||||
val json = JSONArray(orderedKeys).toString()
|
||||
viewModelScope.launch { app.preferences.setQualityPreference(json) }
|
||||
}
|
||||
|
||||
fun setHistoryEnabled(value: Boolean) {
|
||||
viewModelScope.launch { app.preferences.setHistoryEnabled(value) }
|
||||
}
|
||||
|
||||
fun setHistoryFormat(value: String) {
|
||||
viewModelScope.launch { app.preferences.setHistoryFormat(value) }
|
||||
}
|
||||
|
||||
fun setHistoryDirUri(value: String) {
|
||||
viewModelScope.launch { app.preferences.setHistoryDirUri(value) }
|
||||
}
|
||||
|
||||
fun exportPlaylist(
|
||||
@@ -84,4 +118,69 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
fun setTrackHistoryQuery(query: String) {
|
||||
trackHistory.value = query
|
||||
}
|
||||
|
||||
fun resetCustomizations(alsoDeleteStations: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val db = app.database.openHelper.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
db.execSQL("UPDATE stations SET starred = 0, isHidden = 0, qualityOverride = NULL")
|
||||
val cursor = db.query("SELECT id, sortOrder FROM stations ORDER BY id")
|
||||
var idx = 0
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
db.execSQL("UPDATE stations SET sortOrder = $idx WHERE id = $id")
|
||||
idx++
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
if (alsoDeleteStations) {
|
||||
db.execSQL("DELETE FROM stations WHERE playlistId IN (SELECT id FROM playlists WHERE isBuiltIn = 0)")
|
||||
db.execSQL("DELETE FROM stations WHERE playlistId IS NULL")
|
||||
db.execSQL("DELETE FROM playlists WHERE isBuiltIn = 0")
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(app, "Reset complete", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(app, "Reset failed: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restartApp() {
|
||||
_isRestarting.value = true
|
||||
viewModelScope.launch {
|
||||
app.controller.stop()
|
||||
|
||||
var waited = 0L
|
||||
while (waited < 3000L && app.controller.state.value !is PlaybackState.Idle) {
|
||||
delay(200)
|
||||
waited += 200
|
||||
}
|
||||
|
||||
val intent = Intent.makeRestartActivityTask(
|
||||
ComponentName(app, MainActivity::class.java)
|
||||
)
|
||||
app.startActivity(intent)
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseQualityOrder(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) {
|
||||
StreamResolver.DEFAULT_ORDER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package xyz.cottongin.radio247.ui.screens.stationlist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -19,19 +15,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.cottongin.radio247.data.model.Playlist
|
||||
|
||||
@Composable
|
||||
fun AddStationDialog(
|
||||
playlists: List<Playlist>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit,
|
||||
onConfirm: (name: String, url: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
var selectedPlaylistId by remember { mutableStateOf<Long?>(null) }
|
||||
var playlistMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -53,44 +45,13 @@ fun AddStationDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) {
|
||||
OutlinedTextField(
|
||||
value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Playlist") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = playlistMenuExpanded,
|
||||
onDismissRequest = { playlistMenuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No playlist") },
|
||||
onClick = {
|
||||
selectedPlaylistId = null
|
||||
playlistMenuExpanded = false
|
||||
}
|
||||
)
|
||||
for (playlist in playlists) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(playlist.name) },
|
||||
onClick = {
|
||||
selectedPlaylistId = playlist.id
|
||||
playlistMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (name.isNotBlank() && url.isNotBlank()) {
|
||||
onConfirm(name.trim(), url.trim(), selectedPlaylistId)
|
||||
onConfirm(name.trim(), url.trim())
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package xyz.cottongin.radio247.ui.screens.stationlist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -19,21 +15,17 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.cottongin.radio247.data.model.Playlist
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
|
||||
@Composable
|
||||
fun EditStationDialog(
|
||||
station: Station,
|
||||
playlists: List<Playlist>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, url: String, playlistId: Long?) -> Unit,
|
||||
onConfirm: (name: String, url: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var name by remember(station.id) { mutableStateOf(station.name) }
|
||||
var url by remember(station.id) { mutableStateOf(station.url) }
|
||||
var selectedPlaylistId by remember(station.id) { mutableStateOf(station.playlistId) }
|
||||
var playlistMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -55,44 +47,13 @@ fun EditStationDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) {
|
||||
OutlinedTextField(
|
||||
value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Playlist") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = playlistMenuExpanded,
|
||||
onDismissRequest = { playlistMenuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No playlist") },
|
||||
onClick = {
|
||||
selectedPlaylistId = null
|
||||
playlistMenuExpanded = false
|
||||
}
|
||||
)
|
||||
for (playlist in playlists) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(playlist.name) },
|
||||
onClick = {
|
||||
selectedPlaylistId = playlist.id
|
||||
playlistMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (name.isNotBlank() && url.isNotBlank()) {
|
||||
onConfirm(name.trim(), url.trim(), selectedPlaylistId)
|
||||
onConfirm(name.trim(), url.trim())
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@ package xyz.cottongin.radio247.ui.screens.stationlist
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -21,23 +22,31 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.Star
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material.icons.outlined.Star
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,12 +54,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import xyz.cottongin.radio247.data.model.Playlist
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
import xyz.cottongin.radio247.service.PlaybackState
|
||||
import xyz.cottongin.radio247.ui.components.MiniPlayer
|
||||
@@ -62,17 +72,17 @@ fun StationListScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: StationListViewModel = viewModel(
|
||||
factory = StationListViewModelFactory(LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication)
|
||||
factory = StationListViewModelFactory(
|
||||
LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication
|
||||
)
|
||||
)
|
||||
) {
|
||||
val viewState by viewModel.viewState.collectAsState()
|
||||
val playbackState by viewModel.playbackState.collectAsState()
|
||||
val playlists = viewState.playlistsWithStations.map { it.first }
|
||||
|
||||
var showAddStation by remember { mutableStateOf(false) }
|
||||
var showAddPlaylist by remember { mutableStateOf(false) }
|
||||
var stationToEdit by remember { mutableStateOf<Station?>(null) }
|
||||
var expandedPlaylistIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
@@ -81,6 +91,9 @@ fun StationListScreen(
|
||||
}
|
||||
)
|
||||
|
||||
val currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex)
|
||||
val isBuiltInTab = currentTab?.isBuiltIn == true
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -91,22 +104,22 @@ fun StationListScreen(
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(
|
||||
arrayOf(
|
||||
"audio/*",
|
||||
"application/x-mpegurl",
|
||||
"*/*"
|
||||
if (!isBuiltInTab) {
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(
|
||||
arrayOf("audio/*", "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 }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Station")
|
||||
}
|
||||
IconButton(onClick = { showAddPlaylist = true }) {
|
||||
Icon(Icons.Default.Folder, contentDescription = "Add Playlist")
|
||||
if (currentTab?.playlist != null && !isBuiltInTab) {
|
||||
IconButton(onClick = { viewModel.deletePlaylist(currentTab.playlist) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete Tab")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
@@ -136,61 +149,96 @@ fun StationListScreen(
|
||||
PlaybackState.Idle -> null
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
if (viewState.unsortedStations.isNotEmpty()) {
|
||||
item(key = "unsorted_header") {
|
||||
SectionHeader("Unsorted")
|
||||
if (viewState.tabs.size > 1) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = viewState.selectedTabIndex,
|
||||
edgePadding = 16.dp
|
||||
) {
|
||||
viewState.tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = viewState.selectedTabIndex == index,
|
||||
onClick = { viewModel.selectTab(index) },
|
||||
text = {
|
||||
Text(
|
||||
text = tab.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = viewState.unsortedStations,
|
||||
items = viewState.stations,
|
||||
key = { it.id }
|
||||
) { station ->
|
||||
StationRow(
|
||||
station = station,
|
||||
isNowPlaying = station.id == currentPlayingStationId,
|
||||
showListeners = isBuiltInTab,
|
||||
isBuiltIn = isBuiltInTab,
|
||||
isHiddenView = viewState.showHidden,
|
||||
onPlay = { viewModel.playStation(station) },
|
||||
onToggleStar = { viewModel.toggleStar(station) },
|
||||
onEdit = { stationToEdit = station },
|
||||
onDelete = { viewModel.deleteStation(station) }
|
||||
onDelete = { viewModel.deleteStation(station) },
|
||||
onToggleHidden = { viewModel.toggleHidden(station) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for ((playlist, stations) in viewState.playlistsWithStations) {
|
||||
val isExpanded = playlist.id in expandedPlaylistIds
|
||||
item(key = "playlist_header_${playlist.id}") {
|
||||
PlaylistSectionHeader(
|
||||
playlist = playlist,
|
||||
stationCount = stations.size,
|
||||
isExpanded = isExpanded,
|
||||
onToggleExpand = {
|
||||
expandedPlaylistIds = if (isExpanded) {
|
||||
expandedPlaylistIds - playlist.id
|
||||
} else {
|
||||
expandedPlaylistIds + playlist.id
|
||||
}
|
||||
},
|
||||
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) }
|
||||
)
|
||||
if (currentTab?.playlist == null && viewState.stations.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,10 +247,9 @@ fun StationListScreen(
|
||||
|
||||
if (showAddStation) {
|
||||
AddStationDialog(
|
||||
playlists = playlists,
|
||||
onDismiss = { showAddStation = false },
|
||||
onConfirm = { name, url, playlistId ->
|
||||
viewModel.addStation(name, url, playlistId)
|
||||
onConfirm = { name, url ->
|
||||
viewModel.addStation(name, url)
|
||||
showAddStation = false
|
||||
}
|
||||
)
|
||||
@@ -221,10 +268,9 @@ fun StationListScreen(
|
||||
stationToEdit?.let { station ->
|
||||
EditStationDialog(
|
||||
station = station,
|
||||
playlists = playlists,
|
||||
onDismiss = { stationToEdit = null },
|
||||
onConfirm = { name, url, playlistId ->
|
||||
viewModel.updateStation(station, name, url, playlistId)
|
||||
onConfirm = { name, url ->
|
||||
viewModel.updateStation(station, name, url)
|
||||
stationToEdit = null
|
||||
}
|
||||
)
|
||||
@@ -232,55 +278,67 @@ fun StationListScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistSectionHeader(
|
||||
playlist: Playlist,
|
||||
stationCount: Int,
|
||||
isExpanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onToggleStar: () -> Unit,
|
||||
private fun SortChipRow(
|
||||
sortMode: SortMode,
|
||||
isBuiltInTab: Boolean,
|
||||
isPolling: Boolean,
|
||||
hiddenCount: Int,
|
||||
showHidden: Boolean,
|
||||
onSortChanged: (SortMode) -> Unit,
|
||||
onRefreshListeners: () -> Unit,
|
||||
onToggleShowHidden: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggleExpand)
|
||||
.padding(vertical = 8.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Folder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.DEFAULT,
|
||||
onClick = { onSortChanged(SortMode.DEFAULT) },
|
||||
label = { Text("Default") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${playlist.name} ($stationCount)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.NAME_ASC,
|
||||
onClick = { onSortChanged(SortMode.NAME_ASC) },
|
||||
label = { Text("A-Z") }
|
||||
)
|
||||
IconButton(
|
||||
onClick = { onToggleStar() },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (playlist.starred) "Unstar" else "Star",
|
||||
tint = if (playlist.starred) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.NAME_DESC,
|
||||
onClick = { onSortChanged(SortMode.NAME_DESC) },
|
||||
label = { Text("Z-A") }
|
||||
)
|
||||
if (isBuiltInTab) {
|
||||
IconButton(
|
||||
onClick = onRefreshListeners,
|
||||
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(
|
||||
station: Station,
|
||||
isNowPlaying: Boolean,
|
||||
showListeners: Boolean,
|
||||
isBuiltIn: Boolean,
|
||||
isHiddenView: Boolean,
|
||||
onPlay: () -> Unit,
|
||||
onToggleStar: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggleHidden: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val rowAlpha = if (isHiddenView) 0.5f else 1f
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(rowAlpha)
|
||||
.combinedClickable(
|
||||
onClick = onPlay,
|
||||
onLongClick = { showMenu = true }
|
||||
@@ -310,7 +374,7 @@ private fun StationRow(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onToggleStar() },
|
||||
onClick = onToggleStar,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
@@ -335,7 +399,9 @@ private fun StationRow(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (isNowPlaying) {
|
||||
Text(
|
||||
@@ -345,6 +411,14 @@ private fun StationRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showListeners && station.listenerCount > 0) {
|
||||
Text(
|
||||
text = "${station.listenerCount}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
if (isNowPlaying) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
@@ -359,20 +433,39 @@ private fun StationRow(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onEdit()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDelete()
|
||||
}
|
||||
)
|
||||
if (isHiddenView) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Unhide") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onToggleHidden()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Hide") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onToggleHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!isBuiltIn && !isHiddenView) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onEdit()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDelete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,43 @@ import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import xyz.cottongin.radio247.RadioApplication
|
||||
import xyz.cottongin.radio247.data.db.PlaylistDao
|
||||
import xyz.cottongin.radio247.data.db.StationDao
|
||||
import xyz.cottongin.radio247.data.api.SomaFmApi
|
||||
import xyz.cottongin.radio247.data.importing.M3uParser
|
||||
import xyz.cottongin.radio247.data.importing.PlsParser
|
||||
import xyz.cottongin.radio247.data.model.Playlist
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class StationListViewState(
|
||||
val unsortedStations: List<Station>,
|
||||
val playlistsWithStations: List<Pair<Playlist, List<Station>>>
|
||||
data class TabInfo(
|
||||
val playlist: Playlist?,
|
||||
val label: String,
|
||||
val isBuiltIn: Boolean
|
||||
)
|
||||
|
||||
enum class SortMode { DEFAULT, NAME_ASC, NAME_DESC }
|
||||
|
||||
data class StationListViewState(
|
||||
val tabs: List<TabInfo> = emptyList(),
|
||||
val selectedTabIndex: Int = 0,
|
||||
val sortMode: SortMode = SortMode.DEFAULT,
|
||||
val stations: List<Station> = emptyList(),
|
||||
val isPollingListeners: Boolean = false,
|
||||
val hiddenCount: Int = 0,
|
||||
val showHidden: Boolean = false
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StationListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val app = application as RadioApplication
|
||||
private val stationDao = app.database.stationDao()
|
||||
@@ -32,22 +50,154 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
val playbackState = controller.state
|
||||
|
||||
val viewState = playlistDao.getAllPlaylists().flatMapLatest { playlists ->
|
||||
val stationFlows = playlists.map { stationDao.getStationsByPlaylist(it.id) }
|
||||
combine(
|
||||
flowOf(playlists),
|
||||
stationDao.getUnsortedStations(),
|
||||
*stationFlows.toTypedArray()
|
||||
) { array ->
|
||||
val pl = array[0] as List<Playlist>
|
||||
val unsorted = array[1] as List<Station>
|
||||
val stationLists = array.drop(2).map { it as List<Station> }
|
||||
StationListViewState(
|
||||
unsortedStations = unsorted,
|
||||
playlistsWithStations = pl.zip(stationLists) { p, s -> p to s }
|
||||
)
|
||||
private val _selectedTabIndex = MutableStateFlow(0)
|
||||
private val _sortMode = MutableStateFlow(SortMode.DEFAULT)
|
||||
val sortMode: StateFlow<SortMode> = _sortMode.asStateFlow()
|
||||
|
||||
private val _isPollingListeners = MutableStateFlow(false)
|
||||
private val _showHidden = MutableStateFlow(false)
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
private val playlistsFlow = playlistDao.getAllPlaylists()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val currentStationsFlow = combine(
|
||||
_selectedTabIndex, playlistsFlow, _showHidden
|
||||
) { tabIndex, playlists, showHidden ->
|
||||
Triple(tabIndex, playlists, showHidden)
|
||||
}.flatMapLatest { (tabIndex, playlists, showHidden) ->
|
||||
val tabs = buildTabs(playlists)
|
||||
val playlist = if (tabIndex < tabs.size) tabs[tabIndex].playlist else null
|
||||
if (showHidden) {
|
||||
if (playlist == null) stationDao.getHiddenUnsortedStations()
|
||||
else stationDao.getHiddenStationsByPlaylist(playlist.id)
|
||||
} else {
|
||||
if (playlist == null) stationDao.getUnsortedStations()
|
||||
else stationDao.getStationsByPlaylist(playlist.id)
|
||||
}
|
||||
}.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) {
|
||||
viewModelScope.launch {
|
||||
@@ -60,25 +210,21 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
|
||||
viewModelScope.launch { stationDao.toggleStarred(station.id, !station.starred) }
|
||||
}
|
||||
|
||||
fun togglePlaylistStar(playlist: Playlist) {
|
||||
viewModelScope.launch { playlistDao.toggleStarred(playlist.id, !playlist.starred) }
|
||||
}
|
||||
|
||||
fun deleteStation(station: Station) {
|
||||
viewModelScope.launch { stationDao.delete(station) }
|
||||
}
|
||||
|
||||
fun addStation(name: String, url: String, playlistId: Long?) {
|
||||
fun addStation(name: String, url: String) {
|
||||
val currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex)
|
||||
val playlistId = currentTab?.playlist?.id
|
||||
viewModelScope.launch {
|
||||
stationDao.insert(Station(name = name, url = url, playlistId = playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStation(station: Station, name: String, url: String, playlistId: Long?) {
|
||||
fun updateStation(station: Station, name: String, url: String) {
|
||||
viewModelScope.launch {
|
||||
stationDao.update(
|
||||
station.copy(name = name, url = url, playlistId = playlistId)
|
||||
)
|
||||
stationDao.update(station.copy(name = name, url = url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,22 +234,50 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlaylist(playlist: Playlist) {
|
||||
if (playlist.isBuiltIn) return
|
||||
viewModelScope.launch {
|
||||
playlistDao.delete(playlist)
|
||||
if (_selectedTabIndex.value > 0) {
|
||||
_selectedTabIndex.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importFile(uri: Uri) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
|
||||
?: return@launch
|
||||
val fileName = uri.lastPathSegment
|
||||
?.substringAfterLast('/')
|
||||
?.substringBeforeLast('.')
|
||||
?: "Imported"
|
||||
val isM3u = content.trimStart().startsWith("#EXTM3U") ||
|
||||
uri.toString().endsWith(".m3u", ignoreCase = true)
|
||||
val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content)
|
||||
for (station in parsed) {
|
||||
if (parsed.isEmpty()) return@launch
|
||||
|
||||
val playlistId = playlistDao.insert(Playlist(name = fileName))
|
||||
for ((index, station) in parsed.withIndex()) {
|
||||
stationDao.insert(
|
||||
Station(
|
||||
name = station.name,
|
||||
url = station.url,
|
||||
playlistId = playlistId,
|
||||
sortOrder = index,
|
||||
defaultArtworkUrl = station.artworkUrl
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val playlists = playlistsFlow.value
|
||||
val tabs = buildTabs(playlists)
|
||||
val newTabIndex = tabs.indexOfFirst { it.playlist?.id == playlistId }
|
||||
if (newTabIndex >= 0) {
|
||||
_selectedTabIndex.value = newTabIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user