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