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