mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add support for viewing photos in photo albums (#703)
## Description Add support for viewing photos in photo albums ### Features - Browse and view albums & photos - View an album as a slideshow - Show overlay with photo details and controls - Rotate, zoom, & pan photos - Apply basic filters to photos or albums such as contrast, saturation, red/green/blue, etc - Setting to include video clips during slideshows ### Controls - D-Pad left or right: scroll between photos in album - Hold D-Pad up or down: zoom in or out of the current photo - D-Pad left/right/up/down, while zoomed: pan around photo - Back, while zoomed: revert the zoom - Otherwise: show photo overlay ### Related issues Closes #458 Closes #634 ### Screenshots #### Overlay  #### Applying filters 
This commit is contained in:
parent
ee440698b0
commit
d9d5ab02e8
33 changed files with 3128 additions and 32 deletions
|
|
@ -265,6 +265,7 @@ dependencies {
|
||||||
implementation(libs.androidx.preference.ktx)
|
implementation(libs.androidx.preference.ktx)
|
||||||
implementation(libs.androidx.room.testing)
|
implementation(libs.androidx.room.testing)
|
||||||
implementation(libs.androidx.palette.ktx)
|
implementation(libs.androidx.palette.ktx)
|
||||||
|
implementation(libs.androidx.media3.effect)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
ksp(libs.hilt.android.compiler)
|
ksp(libs.hilt.android.compiler)
|
||||||
ksp(libs.androidx.hilt.compiler)
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,635 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 30,
|
||||||
|
"identityHash": "f9b86b73e972aa002238d43ab2c8dcc2",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pin",
|
||||||
|
"columnName": "pin",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_id_serverId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemPlayback",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioIndex",
|
||||||
|
"columnName": "audioIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleIndex",
|
||||||
|
"columnName": "subtitleIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ItemPlayback_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "NavDrawerPinnedItem",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "LibraryDisplayInfo",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sort",
|
||||||
|
"columnName": "sort",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "direction",
|
||||||
|
"columnName": "direction",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filter",
|
||||||
|
"columnName": "filter",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'{}'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewOptions",
|
||||||
|
"columnName": "viewOptions",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_LibraryDisplayInfo_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playback_effects",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.rotation",
|
||||||
|
"columnName": "rotation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.brightness",
|
||||||
|
"columnName": "brightness",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.contrast",
|
||||||
|
"columnName": "contrast",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.saturation",
|
||||||
|
"columnName": "saturation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.hue",
|
||||||
|
"columnName": "hue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.red",
|
||||||
|
"columnName": "red",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.green",
|
||||||
|
"columnName": "green",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blue",
|
||||||
|
"columnName": "blue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blur",
|
||||||
|
"columnName": "blur",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"itemId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "PlaybackLanguageChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioLanguage",
|
||||||
|
"columnName": "audioLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleLanguage",
|
||||||
|
"columnName": "subtitleLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitlesDisabled",
|
||||||
|
"columnName": "subtitlesDisabled",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"seriesId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemTrackModification",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackIndex",
|
||||||
|
"columnName": "trackIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "delayMs",
|
||||||
|
"columnName": "delayMs",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId",
|
||||||
|
"trackIndex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seerr_servers_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authMethod",
|
||||||
|
"columnName": "authMethod",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "credential",
|
||||||
|
"columnName": "credential",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"serverId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seerr_servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"jellyfinUserRowId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 'f9b86b73e972aa002238d43ab2c8dcc2')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
|
|
@ -32,12 +33,14 @@ import java.util.UUID
|
||||||
ItemPlayback::class,
|
ItemPlayback::class,
|
||||||
NavDrawerPinnedItem::class,
|
NavDrawerPinnedItem::class,
|
||||||
LibraryDisplayInfo::class,
|
LibraryDisplayInfo::class,
|
||||||
|
PlaybackEffect::class,
|
||||||
PlaybackLanguageChoice::class,
|
PlaybackLanguageChoice::class,
|
||||||
ItemTrackModification::class,
|
ItemTrackModification::class,
|
||||||
SeerrServer::class,
|
SeerrServer::class,
|
||||||
SeerrUser::class,
|
SeerrUser::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
version = 20,
|
version = 30,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -50,6 +53,7 @@ import java.util.UUID
|
||||||
AutoMigration(10, 11),
|
AutoMigration(10, 11),
|
||||||
AutoMigration(11, 12),
|
AutoMigration(11, 12),
|
||||||
AutoMigration(12, 20),
|
AutoMigration(12, 20),
|
||||||
|
AutoMigration(20, 30),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -65,6 +69,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||||
|
|
||||||
abstract fun seerrServerDao(): SeerrServerDao
|
abstract fun seerrServerDao(): SeerrServerDao
|
||||||
|
|
||||||
|
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PlaybackEffectDao {
|
||||||
|
@Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type")
|
||||||
|
suspend fun getPlaybackEffect(
|
||||||
|
jellyfinUserRowId: Int,
|
||||||
|
itemId: UUID,
|
||||||
|
type: BaseItemKind,
|
||||||
|
): PlaybackEffect?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(playbackEffect: PlaybackEffect)
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,12 @@ data class BaseItem(
|
||||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
||||||
} else if (type == BaseItemKind.SERIES) {
|
} else if (type == BaseItemKind.SERIES) {
|
||||||
data.seriesProductionYears?.let(::add)
|
data.seriesProductionYears?.let(::add)
|
||||||
|
} else if (type == BaseItemKind.PHOTO) {
|
||||||
|
if (data.productionYear != null) {
|
||||||
|
add(data.productionYear!!.toString())
|
||||||
|
} else if (data.premiereDate != null) {
|
||||||
|
add(data.premiereDate!!.toLocalDate().toString())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +160,7 @@ data class BaseItem(
|
||||||
it.dayOfMonth.toString().padStart(2, '0')
|
it.dayOfMonth.toString().padStart(2, '0')
|
||||||
}?.toIntOrNull()
|
}?.toIntOrNull()
|
||||||
|
|
||||||
fun destination(): Destination {
|
fun destination(index: Int? = null): Destination {
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
when (type) {
|
when (type) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
||||||
|
data class PlaybackEffect(
|
||||||
|
val jellyfinUserRowId: Int,
|
||||||
|
val itemId: UUID,
|
||||||
|
val type: BaseItemKind,
|
||||||
|
@Embedded val videoFilter: VideoFilter,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import android.graphics.ColorMatrix
|
||||||
|
import androidx.annotation.IntRange
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.effect.Brightness
|
||||||
|
import androidx.media3.effect.Contrast
|
||||||
|
import androidx.media3.effect.GaussianBlur
|
||||||
|
import androidx.media3.effect.GlEffect
|
||||||
|
import androidx.media3.effect.HslAdjustment
|
||||||
|
import androidx.media3.effect.RgbAdjustment
|
||||||
|
import androidx.media3.effect.ScaleAndRotateTransformation
|
||||||
|
import androidx.room.Ignore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifications to a video playback
|
||||||
|
*/
|
||||||
|
data class VideoFilter(
|
||||||
|
val rotation: Int = 0,
|
||||||
|
@param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 360) val hue: Int = HUE_DEFAULT,
|
||||||
|
@param:IntRange(0, 200) val red: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 200) val green: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT,
|
||||||
|
@param:IntRange(0, 250) val blur: Int = 0,
|
||||||
|
) {
|
||||||
|
@Ignore
|
||||||
|
val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val COLOR_DEFAULT = 100
|
||||||
|
const val HUE_DEFAULT = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0
|
||||||
|
|
||||||
|
fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT
|
||||||
|
|
||||||
|
fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT
|
||||||
|
|
||||||
|
fun hasContrast(): Boolean = contrast != COLOR_DEFAULT
|
||||||
|
|
||||||
|
fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT
|
||||||
|
|
||||||
|
fun hasBlur(): Boolean = blur > 0
|
||||||
|
|
||||||
|
fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private fun rgbAdjustment(): RgbAdjustment =
|
||||||
|
RgbAdjustment
|
||||||
|
.Builder()
|
||||||
|
.setRedScale(red / COLOR_DEFAULT.toFloat())
|
||||||
|
.setGreenScale(green / COLOR_DEFAULT.toFloat())
|
||||||
|
.setBlueScale(blue / COLOR_DEFAULT.toFloat())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the list of effects to apply
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun createEffectList(): List<GlEffect> =
|
||||||
|
buildList {
|
||||||
|
if (isRotated()) {
|
||||||
|
add(
|
||||||
|
ScaleAndRotateTransformation
|
||||||
|
.Builder()
|
||||||
|
.setRotationDegrees(rotation.toFloat())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasRgb()) {
|
||||||
|
add(rgbAdjustment())
|
||||||
|
}
|
||||||
|
if (hasBrightness()) {
|
||||||
|
add(Brightness((brightness - 100) / 100f))
|
||||||
|
}
|
||||||
|
if (hasContrast()) {
|
||||||
|
add(Contrast((contrast - 100) / 100f))
|
||||||
|
}
|
||||||
|
if (hasHsl()) {
|
||||||
|
add(
|
||||||
|
HslAdjustment
|
||||||
|
.Builder()
|
||||||
|
.adjustHue(hue.toFloat())
|
||||||
|
.adjustSaturation((saturation - 100).toFloat())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasBlur()) {
|
||||||
|
add(GaussianBlur(blur / 10f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saturationMatrix(): FloatArray {
|
||||||
|
val rF = 0.2999f
|
||||||
|
val gF = 0.587f
|
||||||
|
val bF = 0.114f
|
||||||
|
val s = saturation / 100.0f
|
||||||
|
|
||||||
|
val ms = 1.0f - s
|
||||||
|
val rT = rF * ms
|
||||||
|
val gT = gF * ms
|
||||||
|
val bT = bF * ms
|
||||||
|
|
||||||
|
val m =
|
||||||
|
FloatArray(20) {
|
||||||
|
when (it) {
|
||||||
|
0 -> (rT + s)
|
||||||
|
1 -> gT
|
||||||
|
2 -> bT
|
||||||
|
5 -> rT
|
||||||
|
6 -> (gT + s)
|
||||||
|
7 -> bT
|
||||||
|
10 -> rT
|
||||||
|
11 -> gT
|
||||||
|
12 -> (bT + s)
|
||||||
|
18 -> 1f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun createColorMatrix(): ColorMatrix {
|
||||||
|
val matrix = ColorMatrix()
|
||||||
|
val tempMatrix = ColorMatrix()
|
||||||
|
|
||||||
|
if (saturation != COLOR_DEFAULT) {
|
||||||
|
matrix.set(saturationMatrix())
|
||||||
|
}
|
||||||
|
if (hasRgb()) {
|
||||||
|
val colorMatrix = rgbAdjustment().getMatrix(0L, false)
|
||||||
|
val m = FloatArray(20)
|
||||||
|
m[0] = colorMatrix[0]
|
||||||
|
m[1] = colorMatrix[1]
|
||||||
|
m[2] = colorMatrix[2]
|
||||||
|
m[3] = colorMatrix[3]
|
||||||
|
m[4] = 0f
|
||||||
|
m[5] = colorMatrix[4]
|
||||||
|
m[6] = colorMatrix[5]
|
||||||
|
m[7] = colorMatrix[6]
|
||||||
|
m[8] = colorMatrix[7]
|
||||||
|
m[9] = 0f
|
||||||
|
m[10] = colorMatrix[8]
|
||||||
|
m[11] = colorMatrix[9]
|
||||||
|
m[12] = colorMatrix[10]
|
||||||
|
m[13] = colorMatrix[11]
|
||||||
|
m[14] = 0f
|
||||||
|
m[15] = colorMatrix[12]
|
||||||
|
m[16] = colorMatrix[13]
|
||||||
|
m[17] = colorMatrix[14]
|
||||||
|
m[18] = colorMatrix[15]
|
||||||
|
m[19] = 0f
|
||||||
|
tempMatrix.set(m)
|
||||||
|
matrix.postConcat(tempMatrix)
|
||||||
|
}
|
||||||
|
if (hasContrast()) {
|
||||||
|
val scale = contrast / 100.0f
|
||||||
|
tempMatrix.setScale(scale, scale, scale, 1f)
|
||||||
|
matrix.postConcat(tempMatrix)
|
||||||
|
}
|
||||||
|
if (hasBrightness()) {
|
||||||
|
val b = brightness / 100.0f
|
||||||
|
val m = FloatArray(20)
|
||||||
|
m[0] = b
|
||||||
|
m[6] = b
|
||||||
|
m[12] = b
|
||||||
|
m[18] = 1f
|
||||||
|
tempMatrix.set(m)
|
||||||
|
matrix.postConcat(tempMatrix)
|
||||||
|
}
|
||||||
|
// TODO hue
|
||||||
|
// TODO blur
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix {
|
||||||
|
val matrix =
|
||||||
|
androidx.compose.ui.graphics
|
||||||
|
.ColorMatrix()
|
||||||
|
|
||||||
|
if (saturation != COLOR_DEFAULT) {
|
||||||
|
matrix.setToSaturation(saturation / 100f)
|
||||||
|
}
|
||||||
|
if (hasRgb()) {
|
||||||
|
matrix.setToScale(
|
||||||
|
redScale = red / 100f,
|
||||||
|
greenScale = green / 100f,
|
||||||
|
blueScale = blue / 100f,
|
||||||
|
alphaScale = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasContrast()) {
|
||||||
|
val scale = contrast / 100.0f
|
||||||
|
val tempMatrix =
|
||||||
|
androidx.compose.ui.graphics
|
||||||
|
.ColorMatrix()
|
||||||
|
tempMatrix.setToScale(scale, scale, scale, 1f)
|
||||||
|
matrix.timesAssign(tempMatrix)
|
||||||
|
}
|
||||||
|
if (hasBrightness()) {
|
||||||
|
val b = brightness / 100.0f
|
||||||
|
val m = FloatArray(20)
|
||||||
|
m[0] = b
|
||||||
|
m[6] = b
|
||||||
|
m[12] = b
|
||||||
|
m[18] = 1f
|
||||||
|
val tempMatrix =
|
||||||
|
androidx.compose.ui.graphics
|
||||||
|
.ColorMatrix(m)
|
||||||
|
matrix.timesAssign(tempMatrix)
|
||||||
|
}
|
||||||
|
// TODO hue
|
||||||
|
// TODO blur
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -903,6 +903,46 @@ sealed interface AppPreference<Pref, T> {
|
||||||
getter = { },
|
getter = { },
|
||||||
setter = { prefs, _ -> prefs },
|
setter = { prefs, _ -> prefs },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val SlideshowDuration =
|
||||||
|
AppSliderPreference<AppPreferences>(
|
||||||
|
title = R.string.slideshow_duration,
|
||||||
|
defaultValue = 5_000,
|
||||||
|
min = 1_000,
|
||||||
|
max = 30.seconds.inWholeMilliseconds,
|
||||||
|
interval = 250,
|
||||||
|
getter = {
|
||||||
|
it.photoPreferences.slideshowDuration
|
||||||
|
},
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePhotoPreferences {
|
||||||
|
slideshowDuration = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
summarizer = { value ->
|
||||||
|
if (value != null) {
|
||||||
|
val seconds = value / 1000.0
|
||||||
|
WholphinApplication.instance.resources.getString(
|
||||||
|
R.string.decimal_seconds,
|
||||||
|
seconds,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val SlideshowPlayVideos =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.play_videos_during_slideshow,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.photoPreferences.slideshowPlayVideos },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePhotoPreferences { slideshowPlayVideos = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,6 +1055,8 @@ val advancedPreferences =
|
||||||
// AppPreference.NavDrawerSwitchOnFocus,
|
// AppPreference.NavDrawerSwitchOnFocus,
|
||||||
AppPreference.ControllerTimeout,
|
AppPreference.ControllerTimeout,
|
||||||
AppPreference.BackdropStylePref,
|
AppPreference.BackdropStylePref,
|
||||||
|
AppPreference.SlideshowDuration,
|
||||||
|
AppPreference.SlideshowPlayVideos,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,14 @@ class AppPreferencesSerializer
|
||||||
imageDiskCacheSizeBytes =
|
imageDiskCacheSizeBytes =
|
||||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
photoPreferences =
|
||||||
|
PhotoPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||||
|
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||||
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||||
|
|
@ -186,6 +194,11 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B
|
||||||
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
|
update {
|
||||||
|
photoPreferences = photoPreferences.toBuilder().apply(block).build()
|
||||||
|
}
|
||||||
|
|
||||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||||
|
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
|
|
@ -228,4 +229,12 @@ suspend fun upgradeApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updatePhotoPreferences {
|
||||||
|
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ class PlaybackLifecycleObserver
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
val lastDest = navigationManager.backStack.lastOrNull()
|
val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
|
if (lastDest is Destination.Playback ||
|
||||||
|
lastDest is Destination.PlaybackList ||
|
||||||
|
lastDest is Destination.Slideshow
|
||||||
|
) {
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
}
|
}
|
||||||
wasPlaying = null
|
wasPlaying = null
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
|
|
@ -66,6 +67,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ val DefaultItemFields =
|
||||||
ItemFields.CHAPTERS,
|
ItemFields.CHAPTERS,
|
||||||
ItemFields.MEDIA_SOURCES,
|
ItemFields.MEDIA_SOURCES,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
|
ItemFields.PARENT_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,6 +71,14 @@ val SlimItemFields =
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
|
ItemFields.PARENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
val PhotoItemFields =
|
||||||
|
DefaultItemFields +
|
||||||
|
listOf(
|
||||||
|
ItemFields.WIDTH,
|
||||||
|
ItemFields.HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
object Cards {
|
object Cards {
|
||||||
|
|
|
||||||
|
|
@ -577,12 +577,35 @@ fun CollectionFolderGrid(
|
||||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled,
|
||||||
onClickPlay = { _, item ->
|
onClickPlay = { index, item ->
|
||||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
val destination =
|
||||||
|
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||||
|
Destination.Slideshow(
|
||||||
|
parentId = item.id,
|
||||||
|
index = index,
|
||||||
|
filter = CollectionFolderFilter(filter = filter),
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
recursive = true,
|
||||||
|
startSlideshow = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.Playback(item)
|
||||||
|
}
|
||||||
|
viewModel.navigationManager.navigateTo(destination)
|
||||||
},
|
},
|
||||||
onClickPlayAll = { shuffle ->
|
onClickPlayAll = { shuffle ->
|
||||||
itemId.toUUIDOrNull()?.let {
|
itemId.toUUIDOrNull()?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
val destination =
|
||||||
|
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||||
|
Destination.Slideshow(
|
||||||
|
parentId = it,
|
||||||
|
index = 0,
|
||||||
|
filter = CollectionFolderFilter(filter = filter),
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
recursive = true,
|
||||||
|
startSlideshow = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Destination.PlaybackList(
|
Destination.PlaybackList(
|
||||||
itemId = it,
|
itemId = it,
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
|
|
@ -590,9 +613,10 @@ fun CollectionFolderGrid(
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
sortAndDirection = sortAndDirection,
|
sortAndDirection = sortAndDirection,
|
||||||
filter = filter,
|
filter = filter,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
viewModel.navigationManager.navigateTo(destination)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -180,6 +182,63 @@ fun ExpandablePlayButton(
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
mirrorIcon: Boolean = false,
|
mirrorIcon: Boolean = false,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
) = ExpandablePlayButton(
|
||||||
|
title = title,
|
||||||
|
resume = resume,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandablePlayButton(
|
||||||
|
@StringRes title: Int,
|
||||||
|
resume: Duration,
|
||||||
|
icon: Painter,
|
||||||
|
onClick: (position: Duration) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
mirrorIcon: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) = ExpandablePlayButton(
|
||||||
|
title = title,
|
||||||
|
resume = resume,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandablePlayButton(
|
||||||
|
@StringRes title: Int,
|
||||||
|
resume: Duration,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
onClick: (position: Duration) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
Button(
|
Button(
|
||||||
|
|
@ -200,14 +259,7 @@ fun ExpandablePlayButton(
|
||||||
.padding(start = 2.dp, top = 2.dp)
|
.padding(start = 2.dp, top = 2.dp)
|
||||||
.height(MinButtonSize),
|
.height(MinButtonSize),
|
||||||
) {
|
) {
|
||||||
Icon(
|
icon.invoke()
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(28.dp)
|
|
||||||
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
AnimatedVisibility(isFocused) {
|
AnimatedVisibility(isFocused) {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
@ -343,6 +395,13 @@ private fun ViewOptionsPreview() {
|
||||||
onClick = {},
|
onClick = {},
|
||||||
interactionSource = source,
|
interactionSource = source,
|
||||||
)
|
)
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.play,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = painterResource(R.drawable.baseline_pause_24),
|
||||||
|
onClick = {},
|
||||||
|
interactionSource = source,
|
||||||
|
)
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.play,
|
title = R.string.play,
|
||||||
iconStringRes = R.string.fa_eye,
|
iconStringRes = R.string.fa_eye,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ fun SliderBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
interval: Int = 1,
|
interval: Int = 1,
|
||||||
color: Color = MaterialTheme.colorScheme.border,
|
colors: SliderColors = SliderColors.default(),
|
||||||
) {
|
) {
|
||||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
val animatedIndicatorHeight by animateDpAsState(
|
val animatedIndicatorHeight by animateDpAsState(
|
||||||
|
|
@ -49,9 +49,6 @@ fun SliderBar(
|
||||||
var currentValue by remember(value) { mutableLongStateOf(value) }
|
var currentValue by remember(value) { mutableLongStateOf(value) }
|
||||||
val percent = (currentValue - min).toFloat() / (max - min)
|
val percent = (currentValue - min).toFloat() / (max - min)
|
||||||
|
|
||||||
val activeColor = SliderActiveColor(isFocused)
|
|
||||||
val inactiveColor = SliderInactiveColor(isFocused)
|
|
||||||
|
|
||||||
val handleSeekEventModifier =
|
val handleSeekEventModifier =
|
||||||
Modifier.handleDPadKeyEvents(
|
Modifier.handleDPadKeyEvents(
|
||||||
triggerOnAction = KeyEvent.ACTION_DOWN,
|
triggerOnAction = KeyEvent.ACTION_DOWN,
|
||||||
|
|
@ -91,14 +88,14 @@ fun SliderBar(
|
||||||
onDraw = {
|
onDraw = {
|
||||||
val yOffset = size.height.div(2)
|
val yOffset = size.height.div(2)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = inactiveColor,
|
color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused,
|
||||||
start = Offset(x = 0f, y = yOffset),
|
start = Offset(x = 0f, y = yOffset),
|
||||||
end = Offset(x = size.width, y = yOffset),
|
end = Offset(x = size.width, y = yOffset),
|
||||||
strokeWidth = size.height,
|
strokeWidth = size.height,
|
||||||
cap = StrokeCap.Round,
|
cap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = activeColor,
|
color = if (isFocused) colors.activeFocused else colors.activeUnfocused,
|
||||||
start = Offset(x = 0f, y = yOffset),
|
start = Offset(x = 0f, y = yOffset),
|
||||||
end =
|
end =
|
||||||
Offset(
|
Offset(
|
||||||
|
|
@ -119,6 +116,24 @@ fun SliderBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SliderColors(
|
||||||
|
val activeFocused: Color,
|
||||||
|
val activeUnfocused: Color,
|
||||||
|
val inactiveFocused: Color,
|
||||||
|
val inactiveUnfocused: Color,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
@Composable
|
||||||
|
fun default() =
|
||||||
|
SliderColors(
|
||||||
|
activeFocused = SliderActiveColor(true),
|
||||||
|
activeUnfocused = SliderActiveColor(false),
|
||||||
|
inactiveFocused = SliderInactiveColor(true),
|
||||||
|
inactiveUnfocused = SliderInactiveColor(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SliderActiveColor(focused: Boolean): Color {
|
fun SliderActiveColor(focused: Boolean): Color {
|
||||||
val theme = LocalTheme.current
|
val theme = LocalTheme.current
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ fun CollectionFolderGeneric(
|
||||||
}
|
}
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
|
onClickItem = { index, item ->
|
||||||
|
preferencesViewModel.navigationManager.navigateTo(item.destination(index))
|
||||||
|
},
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
initialFilter = filter,
|
initialFilter = filter,
|
||||||
showTitle = showHeader,
|
showTitle = showHeader,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||||
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||||
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionFolderPhotoAlbum(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
itemId: UUID,
|
||||||
|
recursive: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||||
|
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||||
|
sortOptions: List<ItemSortBy> = VideoSortOptions,
|
||||||
|
// Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items
|
||||||
|
// TODO refactor this
|
||||||
|
viewModel: CollectionFolderViewModel =
|
||||||
|
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||||
|
key = itemId.toServerString(),
|
||||||
|
) {
|
||||||
|
it.create(
|
||||||
|
itemId = itemId.toServerString(),
|
||||||
|
initialSortAndDirection = null,
|
||||||
|
recursive = recursive,
|
||||||
|
collectionFilter = filter,
|
||||||
|
useSeriesForPrimary = false,
|
||||||
|
defaultViewOptions = ViewOptionsWide,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
var showHeader by remember { mutableStateOf(true) }
|
||||||
|
CollectionFolderGrid(
|
||||||
|
preferences = preferences,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
val destination =
|
||||||
|
if (item.type == BaseItemKind.PHOTO) {
|
||||||
|
Destination.Slideshow(
|
||||||
|
parentId = itemId,
|
||||||
|
index = index,
|
||||||
|
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
|
||||||
|
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
|
||||||
|
recursive = true,
|
||||||
|
startSlideshow = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
item.destination(index)
|
||||||
|
}
|
||||||
|
viewModel.navigationManager.navigateTo(destination)
|
||||||
|
},
|
||||||
|
itemId = itemId.toServerString(),
|
||||||
|
initialFilter = filter,
|
||||||
|
showTitle = showHeader,
|
||||||
|
recursive = recursive,
|
||||||
|
sortOptions = sortOptions,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(start = 16.dp),
|
||||||
|
positionCallback = { columns, position ->
|
||||||
|
showHeader = position < columns
|
||||||
|
},
|
||||||
|
defaultViewOptions = ViewOptionsWide,
|
||||||
|
playEnabled = true,
|
||||||
|
filterOptions = filterOptions,
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -105,6 +105,16 @@ sealed class Destination(
|
||||||
val itemIds: List<UUID>,
|
val itemIds: List<UUID>,
|
||||||
) : Destination(false)
|
) : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Slideshow(
|
||||||
|
val parentId: UUID,
|
||||||
|
val index: Int,
|
||||||
|
val filter: CollectionFolderFilter,
|
||||||
|
val sortAndDirection: SortAndDirection,
|
||||||
|
val recursive: Boolean,
|
||||||
|
val startSlideshow: Boolean,
|
||||||
|
) : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Favorites : Destination(false)
|
data object Favorites : Destination(false)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
||||||
|
|
@ -36,6 +37,7 @@ import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||||
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
|
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
|
||||||
|
import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -195,6 +197,16 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.PHOTO_ALBUM -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
|
CollectionFolderPhotoAlbum(
|
||||||
|
preferences = preferences,
|
||||||
|
itemId = destination.itemId,
|
||||||
|
recursive = true,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unsupported item type: ${destination.type}")
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}")
|
||||||
|
|
@ -234,6 +246,12 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is Destination.Slideshow -> {
|
||||||
|
SlideshowPage(
|
||||||
|
slideshow = destination,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Destination.Favorites -> {
|
Destination.Favorites -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
FavoritesPage(
|
FavoritesPage(
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@ fun SliderPreference(
|
||||||
max = preference.max,
|
max = preference.max,
|
||||||
interval = preference.interval,
|
interval = preference.interval,
|
||||||
onChange = onChange,
|
onChange = onChange,
|
||||||
color = MaterialTheme.colorScheme.border,
|
|
||||||
enableWrapAround = false,
|
enableWrapAround = false,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusState
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ImageControlsOverlay(
|
||||||
|
slideshowEnabled: Boolean,
|
||||||
|
slideshowControls: SlideshowControls,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isImageClip: Boolean,
|
||||||
|
onZoom: (Float) -> Unit,
|
||||||
|
onRotate: (Int) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
isPlaying: Boolean,
|
||||||
|
playPauseOnClick: () -> Unit,
|
||||||
|
onShowFilterDialogClick: () -> Unit,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
val onFocused = { focusState: FocusState ->
|
||||||
|
if (focusState.isFocused && bringIntoViewRequester != null) {
|
||||||
|
scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow,
|
||||||
|
icon =
|
||||||
|
painterResource(
|
||||||
|
if (slideshowEnabled) {
|
||||||
|
R.drawable.baseline_pause_24
|
||||||
|
} else {
|
||||||
|
R.drawable.baseline_play_arrow_24
|
||||||
|
},
|
||||||
|
),
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
onClick = {
|
||||||
|
if (slideshowEnabled) {
|
||||||
|
slideshowControls.stopSlideshow()
|
||||||
|
} else {
|
||||||
|
slideshowControls.startSlideshow()
|
||||||
|
onDismiss.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isImageClip) {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = playPauseOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter =
|
||||||
|
painterResource(
|
||||||
|
if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular image
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.rotate_left,
|
||||||
|
iconStringRes = R.string.fa_rotate_left,
|
||||||
|
onClick = { onRotate(-90) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.rotate_right,
|
||||||
|
iconStringRes = R.string.fa_rotate_right,
|
||||||
|
onClick = { onRotate(90) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.zoom_in,
|
||||||
|
iconStringRes = R.string.fa_magnifying_glass_plus,
|
||||||
|
onClick = { onZoom(.15f) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.zoom_out,
|
||||||
|
iconStringRes = R.string.fa_magnifying_glass_minus,
|
||||||
|
onClick = { onZoom(-.15f) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.reset,
|
||||||
|
iconStringRes = R.string.fa_arrows_rotate,
|
||||||
|
onClick = onReset,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.filter,
|
||||||
|
iconStringRes = R.string.fa_sliders,
|
||||||
|
onClick = onShowFilterDialogClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(onFocused),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// More button
|
||||||
|
// item {
|
||||||
|
// ExpandablePlayButton(
|
||||||
|
// title = R.string.more,
|
||||||
|
// resume = Duration.ZERO,
|
||||||
|
// icon = Icons.Default.MoreVert,
|
||||||
|
// onClick = { moreOnClick.invoke() },
|
||||||
|
// modifier = Modifier.onFocusChanged(onFocused),
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 800)
|
||||||
|
@Composable
|
||||||
|
private fun ImageControlsOverlayPreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
ImageControlsOverlay(
|
||||||
|
slideshowEnabled = true,
|
||||||
|
slideshowControls =
|
||||||
|
object : SlideshowControls {
|
||||||
|
override fun startSlideshow() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopSlideshow() {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isImageClip = false,
|
||||||
|
onZoom = {},
|
||||||
|
onRotate = {},
|
||||||
|
onReset = {},
|
||||||
|
moreOnClick = {},
|
||||||
|
isPlaying = false,
|
||||||
|
playPauseOnClick = {},
|
||||||
|
bringIntoViewRequester = null,
|
||||||
|
onShowFilterDialogClick = {},
|
||||||
|
onDismiss = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.components.StreamLabel
|
||||||
|
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ImageDetailsHeader(
|
||||||
|
slideshowEnabled: Boolean,
|
||||||
|
slideshowControls: SlideshowControls,
|
||||||
|
player: Player,
|
||||||
|
image: ImageState,
|
||||||
|
position: Int,
|
||||||
|
count: Int,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
onZoom: (Float) -> Unit,
|
||||||
|
onRotate: (Int) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
onShowFilterDialogClick: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
) {
|
||||||
|
image.image.title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (image.image.ui.quickDetails
|
||||||
|
.isNotNullOrBlank()
|
||||||
|
) {
|
||||||
|
QuickDetails(image.image.ui.quickDetails, null)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
StreamLabel("${position + 1} of $count")
|
||||||
|
if (image.image.data.mediaType == MediaType.VIDEO) {
|
||||||
|
VideoStreamDetails(
|
||||||
|
chosenStreams = image.chosenStreams,
|
||||||
|
numberOfVersions = 0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
image.image.data.let {
|
||||||
|
if (it.width != null && it.height != null) {
|
||||||
|
StreamLabel("${it.width}x${it.height}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OverviewText(
|
||||||
|
overview = image.image.data.overview ?: "",
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
|
ImageControlsOverlay(
|
||||||
|
slideshowEnabled = slideshowEnabled,
|
||||||
|
slideshowControls = slideshowControls,
|
||||||
|
isImageClip = image.image.data.mediaType == MediaType.VIDEO,
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
onZoom = onZoom,
|
||||||
|
onRotate = onRotate,
|
||||||
|
onReset = onReset,
|
||||||
|
moreOnClick = moreOnClick,
|
||||||
|
playPauseOnClick = playPauseState::onClick,
|
||||||
|
isPlaying = playPauseState.showPlay,
|
||||||
|
onShowFilterDialogClick = onShowFilterDialogClick,
|
||||||
|
onDismiss = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||||
|
import com.github.damontecres.wholphin.ui.components.SliderBar
|
||||||
|
import com.github.damontecres.wholphin.ui.components.SliderColors
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
||||||
|
const val DRAG_THROTTLE_DELAY = 50L
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageFilterSliders(
|
||||||
|
filter: VideoFilter,
|
||||||
|
showVideoOptions: Boolean,
|
||||||
|
showSaveButton: Boolean,
|
||||||
|
showSaveGalleryButton: Boolean,
|
||||||
|
onChange: (VideoFilter) -> Unit,
|
||||||
|
onClickSave: () -> Unit,
|
||||||
|
onClickSaveGallery: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.brightness,
|
||||||
|
value = filter.brightness,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(brightness = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.contrast,
|
||||||
|
value = filter.contrast,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(contrast = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.saturation,
|
||||||
|
value = filter.saturation,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(saturation = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showVideoOptions) {
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.hue,
|
||||||
|
value = filter.hue,
|
||||||
|
min = 0,
|
||||||
|
max = 360,
|
||||||
|
onChange = { onChange.invoke(filter.copy(hue = it)) },
|
||||||
|
valueFormater = { "$it\u00b0" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.red,
|
||||||
|
value = filter.red,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(red = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
colors =
|
||||||
|
SliderColors(
|
||||||
|
activeFocused = Color.Red.copy(alpha = .75f),
|
||||||
|
activeUnfocused = Color.Red.copy(alpha = .75f),
|
||||||
|
inactiveFocused = Color.Red.copy(alpha = .33f),
|
||||||
|
inactiveUnfocused = Color.Red.copy(alpha = .33f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.green,
|
||||||
|
value = filter.green,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(green = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
colors =
|
||||||
|
SliderColors(
|
||||||
|
activeFocused = Color.Green.copy(alpha = .75f),
|
||||||
|
activeUnfocused = Color.Green.copy(alpha = .75f),
|
||||||
|
inactiveFocused = Color.Green.copy(alpha = .33f),
|
||||||
|
inactiveUnfocused = Color.Green.copy(alpha = .33f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.blue,
|
||||||
|
value = filter.blue,
|
||||||
|
min = 0,
|
||||||
|
max = 200,
|
||||||
|
onChange = { onChange.invoke(filter.copy(blue = it)) },
|
||||||
|
valueFormater = { "$it%" },
|
||||||
|
colors =
|
||||||
|
SliderColors(
|
||||||
|
activeFocused = Color.Blue.copy(alpha = .75f),
|
||||||
|
activeUnfocused = Color.Blue.copy(alpha = .75f),
|
||||||
|
inactiveFocused = Color.Blue.copy(alpha = .33f),
|
||||||
|
inactiveUnfocused = Color.Blue.copy(alpha = .33f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showVideoOptions) {
|
||||||
|
item {
|
||||||
|
SliderBarRow(
|
||||||
|
title = R.string.blur,
|
||||||
|
value = filter.blur,
|
||||||
|
min = 0,
|
||||||
|
max = 250,
|
||||||
|
onChange = { onChange.invoke(filter.copy(blur = it)) },
|
||||||
|
valueFormater = { "${it}px" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (showSaveButton) {
|
||||||
|
Button(
|
||||||
|
onClick = onClickSave,
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showSaveGalleryButton) {
|
||||||
|
Button(
|
||||||
|
onClick = onClickSaveGallery,
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.save_for_album))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { onChange(VideoFilter()) },
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SliderBarRow(
|
||||||
|
@StringRes title: Int,
|
||||||
|
value: Int,
|
||||||
|
min: Int,
|
||||||
|
max: Int,
|
||||||
|
onChange: (Int) -> Unit,
|
||||||
|
valueFormater: (Int) -> String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
interval: Int = 1,
|
||||||
|
colors: SliderColors = SliderColors.default(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(title),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.width(96.dp),
|
||||||
|
)
|
||||||
|
SliderBar(
|
||||||
|
value = value.toLong(),
|
||||||
|
min = min.toLong(),
|
||||||
|
max = max.toLong(),
|
||||||
|
interval = interval,
|
||||||
|
onChange = {
|
||||||
|
onChange.invoke(it.toInt())
|
||||||
|
},
|
||||||
|
colors = colors,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
enableWrapAround = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = valueFormater(value),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.width(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageFilterDialog(
|
||||||
|
filter: VideoFilter,
|
||||||
|
showVideoOptions: Boolean,
|
||||||
|
showSaveGalleryButton: Boolean,
|
||||||
|
onChange: (VideoFilter) -> Unit,
|
||||||
|
onClickSave: () -> Unit,
|
||||||
|
onClickSaveGallery: () -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.let { window ->
|
||||||
|
window.setGravity(Gravity.TOP or Gravity.END)
|
||||||
|
window.setDimAmount(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.wrapContentSize()
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f))
|
||||||
|
.fillMaxWidth(.4f),
|
||||||
|
) {
|
||||||
|
ImageFilterSliders(
|
||||||
|
filter = filter,
|
||||||
|
showVideoOptions = showVideoOptions,
|
||||||
|
showSaveButton = true,
|
||||||
|
showSaveGalleryButton = showSaveGalleryButton,
|
||||||
|
onChange = onChange,
|
||||||
|
onClickSave = onClickSave,
|
||||||
|
onClickSaveGallery = onClickSaveGallery,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ImageFilterSlidersPreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
ImageFilterSliders(
|
||||||
|
filter = VideoFilter(),
|
||||||
|
showVideoOptions = true,
|
||||||
|
onChange = {},
|
||||||
|
onClickSave = {},
|
||||||
|
onClickSaveGallery = {},
|
||||||
|
showSaveButton = true,
|
||||||
|
showSaveGalleryButton = true,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageLoadingPlaceholder(
|
||||||
|
thumbnailUrl: String?,
|
||||||
|
showThumbnail: Boolean,
|
||||||
|
colorFilter: ColorFilter?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(thumbnailUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
colorFilter = colorFilter,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.alpha(.75f)
|
||||||
|
.blur(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CircularProgress(
|
||||||
|
Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ImageOverlay(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
player: Player,
|
||||||
|
slideshowControls: SlideshowControls,
|
||||||
|
slideshowEnabled: Boolean,
|
||||||
|
position: Int,
|
||||||
|
count: Int,
|
||||||
|
image: ImageState,
|
||||||
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
onLongClickItem: (BaseItem) -> Unit,
|
||||||
|
onZoom: (Float) -> Unit,
|
||||||
|
onRotate: (Int) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
onShowFilterDialogClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
|
val moreDialogParams =
|
||||||
|
remember {
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = "TODO",
|
||||||
|
items =
|
||||||
|
listOf(
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (slideshowEnabled) {
|
||||||
|
stringResource(R.string.stop_slideshow)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.play_slideshow)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
val icon =
|
||||||
|
if (slideshowEnabled) {
|
||||||
|
R.drawable.baseline_pause_24
|
||||||
|
} else {
|
||||||
|
R.drawable.baseline_play_arrow_24
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (slideshowEnabled) {
|
||||||
|
slideshowControls.stopSlideshow()
|
||||||
|
} else {
|
||||||
|
slideshowControls.startSlideshow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.filter),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_sliders),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onShowFilterDialogClick,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val horizontalPadding = 16.dp
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding =
|
||||||
|
PaddingValues(
|
||||||
|
start = horizontalPadding,
|
||||||
|
end = horizontalPadding,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
ImageDetailsHeader(
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
slideshowEnabled = slideshowEnabled,
|
||||||
|
slideshowControls = slideshowControls,
|
||||||
|
player = player,
|
||||||
|
image = image,
|
||||||
|
position = position,
|
||||||
|
count = count,
|
||||||
|
moreOnClick = {
|
||||||
|
showDialog = moreDialogParams
|
||||||
|
},
|
||||||
|
onZoom = onZoom,
|
||||||
|
onRotate = onRotate,
|
||||||
|
onReset = onReset,
|
||||||
|
onShowFilterDialogClick = onShowFilterDialogClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { showDialog = null },
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,509 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorMatrixColorFilter
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
|
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||||
|
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.size.Size
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||||
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
|
import com.github.damontecres.wholphin.ui.keepScreenOn
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isDpad
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isEnterKey
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
private const val TAG = "ImagePage"
|
||||||
|
private const val DEBUG = false
|
||||||
|
|
||||||
|
@SuppressLint("ConfigurationScreenWidthHeight")
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SlideshowPage(
|
||||||
|
slideshow: Destination.Slideshow,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SlideshowViewModel =
|
||||||
|
hiltViewModel<SlideshowViewModel, SlideshowViewModel.Factory>(
|
||||||
|
creationCallback = {
|
||||||
|
it.create(slideshow)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||||
|
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||||
|
val position by viewModel.position.observeAsState(0)
|
||||||
|
val pager by viewModel.pager.observeAsState()
|
||||||
|
val imageState by viewModel.image.observeAsState()
|
||||||
|
|
||||||
|
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||||
|
val isZoomed = zoomFactor * 100 > 102
|
||||||
|
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
|
var showOverlay by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var panX by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
|
var panY by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
val slideshowControls =
|
||||||
|
object : SlideshowControls {
|
||||||
|
override fun startSlideshow() {
|
||||||
|
showOverlay = false
|
||||||
|
viewModel.startSlideshow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopSlideshow() {
|
||||||
|
viewModel.stopSlideshow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rotateAnimation: Float by animateFloatAsState(
|
||||||
|
targetValue = rotation,
|
||||||
|
label = "image_rotation",
|
||||||
|
)
|
||||||
|
val zoomAnimation: Float by animateFloatAsState(
|
||||||
|
targetValue = zoomFactor,
|
||||||
|
label = "image_zoom",
|
||||||
|
)
|
||||||
|
val panXAnimation: Float by animateFloatAsState(
|
||||||
|
targetValue = panX,
|
||||||
|
label = "image_panX",
|
||||||
|
)
|
||||||
|
val panYAnimation: Float by animateFloatAsState(
|
||||||
|
targetValue = panY,
|
||||||
|
label = "image_panY",
|
||||||
|
)
|
||||||
|
|
||||||
|
val slideshowState by viewModel.slideshow.collectAsState()
|
||||||
|
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val screenHeight = LocalWindowInfo.current.containerSize.height
|
||||||
|
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||||
|
|
||||||
|
val maxPanX = screenWidth * .75f
|
||||||
|
val maxPanY = screenHeight * .75f
|
||||||
|
|
||||||
|
fun reset(resetRotate: Boolean) {
|
||||||
|
zoomFactor = 1f
|
||||||
|
panX = 0f
|
||||||
|
panY = 0f
|
||||||
|
if (resetRotate) rotation = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pan(
|
||||||
|
xFactor: Int,
|
||||||
|
yFactor: Int,
|
||||||
|
) {
|
||||||
|
if (xFactor != 0) {
|
||||||
|
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
|
||||||
|
}
|
||||||
|
if (yFactor != 0) {
|
||||||
|
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zoom(factor: Float) {
|
||||||
|
if (factor < 0) {
|
||||||
|
val diffFactor = factor / (zoomFactor - 1f)
|
||||||
|
// zooming out
|
||||||
|
val panXDiff = abs(panX * diffFactor)
|
||||||
|
val panYDiff = abs(panY * diffFactor)
|
||||||
|
if (DEBUG) {
|
||||||
|
Timber.d(
|
||||||
|
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (panX > 0f) {
|
||||||
|
panX -= panXDiff
|
||||||
|
} else if (panX < 0f) {
|
||||||
|
panX += panXDiff
|
||||||
|
}
|
||||||
|
if (panY > 0f) {
|
||||||
|
panY -= panYDiff
|
||||||
|
} else if (panY < 0f) {
|
||||||
|
panY += panYDiff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
|
||||||
|
if (!isZoomed) {
|
||||||
|
// Always reset if not zoomed
|
||||||
|
panX = 0f
|
||||||
|
panY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(imageState) {
|
||||||
|
reset(true)
|
||||||
|
}
|
||||||
|
val player = viewModel.player
|
||||||
|
val presentationState = rememberPresentationState(player)
|
||||||
|
LaunchedEffect(slideshowActive) {
|
||||||
|
player.repeatMode =
|
||||||
|
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
||||||
|
context.findActivity()?.keepScreenOn(slideshowActive)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
context.findActivity()?.keepScreenOn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var longPressing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val contentModifier =
|
||||||
|
Modifier
|
||||||
|
.clickable(
|
||||||
|
interactionSource = null,
|
||||||
|
indication = null,
|
||||||
|
onClick = {
|
||||||
|
showOverlay = !showOverlay
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.background(Color.Black)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onKeyEvent {
|
||||||
|
val isOverlayShowing = showOverlay || showFilterDialog
|
||||||
|
var result = false
|
||||||
|
if (!isOverlayShowing) {
|
||||||
|
if (longPressing && it.type == KeyEventType.KeyUp) {
|
||||||
|
// User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image
|
||||||
|
longPressing = false
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
longPressing =
|
||||||
|
it.nativeKeyEvent.isLongPress ||
|
||||||
|
it.nativeKeyEvent.repeatCount > 0
|
||||||
|
if (longPressing) {
|
||||||
|
when (it.key) {
|
||||||
|
Key.DirectionUp -> zoom(.05f)
|
||||||
|
Key.DirectionDown -> zoom(-.05f)
|
||||||
|
|
||||||
|
// These work, but feel awkward because Up/Down zoom, so you can't long press them to pan
|
||||||
|
// Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() }
|
||||||
|
// Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() }
|
||||||
|
}
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (it.type != KeyEventType.KeyUp) {
|
||||||
|
result = false
|
||||||
|
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
|
||||||
|
// Image is zoomed in
|
||||||
|
when (it.key) {
|
||||||
|
Key.DirectionLeft -> pan(30, 0)
|
||||||
|
Key.DirectionRight -> pan(-30, 0)
|
||||||
|
Key.DirectionUp -> pan(0, 30)
|
||||||
|
Key.DirectionDown -> pan(0, -30)
|
||||||
|
}
|
||||||
|
result = true
|
||||||
|
} else if (!isOverlayShowing && isZoomed && it.key == Key.Back) {
|
||||||
|
reset(false)
|
||||||
|
result = true
|
||||||
|
} else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) {
|
||||||
|
when (it.key) {
|
||||||
|
Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> {
|
||||||
|
if (!viewModel.previousImage()) {
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
R.string.slideshow_at_beginning,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
|
||||||
|
if (!viewModel.nextImage()) {
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
R.string.no_more_images,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isOverlayShowing && it.key == Key.Back) {
|
||||||
|
showOverlay = false
|
||||||
|
viewModel.unpauseSlideshow()
|
||||||
|
result = true
|
||||||
|
} else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) {
|
||||||
|
showOverlay = true
|
||||||
|
viewModel.pauseSlideshow()
|
||||||
|
result = true
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
// Handled the key, so reset the slideshow timer
|
||||||
|
viewModel.pulseSlideshow()
|
||||||
|
}
|
||||||
|
result
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
when (loadingState) {
|
||||||
|
ImageLoadingState.Error -> {
|
||||||
|
ErrorMessage("Error loading image", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageLoadingState.Loading -> {
|
||||||
|
LoadingPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ImageLoadingState.Success -> {
|
||||||
|
imageState?.let { imageState ->
|
||||||
|
if (imageState.image.data.mediaType == MediaType.VIDEO) {
|
||||||
|
LaunchedEffect(imageState.id) {
|
||||||
|
val mediaItem =
|
||||||
|
MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setUri(imageState.url)
|
||||||
|
.build()
|
||||||
|
player.setMediaItem(mediaItem)
|
||||||
|
player.repeatMode =
|
||||||
|
if (slideshowState.enabled) {
|
||||||
|
Player.REPEAT_MODE_OFF
|
||||||
|
} else {
|
||||||
|
Player.REPEAT_MODE_ONE
|
||||||
|
}
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||||
|
}
|
||||||
|
LifecycleStartEffect(Unit) {
|
||||||
|
onStopOrDispose {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val contentScale = ContentScale.Fit
|
||||||
|
val scaledModifier =
|
||||||
|
contentModifier.resizeWithContentScale(
|
||||||
|
contentScale,
|
||||||
|
presentationState.videoSizeDp,
|
||||||
|
)
|
||||||
|
PlayerSurface(
|
||||||
|
player = player,
|
||||||
|
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||||
|
modifier =
|
||||||
|
scaledModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = zoomAnimation
|
||||||
|
scaleY = zoomAnimation
|
||||||
|
translationX = panXAnimation
|
||||||
|
translationY = panYAnimation
|
||||||
|
}.rotate(rotateAnimation),
|
||||||
|
)
|
||||||
|
if (presentationState.coverSurface) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val colorFilter =
|
||||||
|
remember(imageState.id, imageFilter) {
|
||||||
|
if (imageFilter.hasImageFilter()) {
|
||||||
|
ColorMatrixColorFilter(imageFilter.colorMatrix)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the image loading is large, show the thumbnail while waiting
|
||||||
|
// TODO
|
||||||
|
val showLoadingThumbnail = true
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
modifier =
|
||||||
|
contentModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = zoomAnimation
|
||||||
|
scaleY = zoomAnimation
|
||||||
|
translationX = panXAnimation
|
||||||
|
translationY = panYAnimation
|
||||||
|
|
||||||
|
val xTransform =
|
||||||
|
(screenWidth - panXAnimation) / (screenWidth * 2)
|
||||||
|
val yTransform =
|
||||||
|
(screenHeight - panYAnimation) / (screenHeight * 2)
|
||||||
|
if (DEBUG) {
|
||||||
|
Timber.d(
|
||||||
|
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
transformOrigin = TransformOrigin(xTransform, yTransform)
|
||||||
|
}.rotate(rotateAnimation),
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
|
.data(imageState.url)
|
||||||
|
.size(Size.ORIGINAL)
|
||||||
|
.crossfade(!showLoadingThumbnail)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
colorFilter = colorFilter,
|
||||||
|
error = {
|
||||||
|
Text(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center),
|
||||||
|
text = "Error loading image",
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loading = {
|
||||||
|
ImageLoadingPlaceholder(
|
||||||
|
thumbnailUrl = imageState.thumbnailUrl,
|
||||||
|
showThumbnail = showLoadingThumbnail,
|
||||||
|
colorFilter = colorFilter,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Ensure that if an image takes a long time to load, it won't be skipped
|
||||||
|
onLoading = {
|
||||||
|
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
viewModel.pulseSlideshow()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
Timber.e(
|
||||||
|
it.result.throwable,
|
||||||
|
"Error loading image ${imageState.id}",
|
||||||
|
)
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
"Error loading image: ${it.result.throwable.localizedMessage}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
viewModel.pulseSlideshow()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
showOverlay,
|
||||||
|
enter = slideInVertically { it },
|
||||||
|
exit = slideOutVertically { it },
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
) {
|
||||||
|
imageState?.let { imageState ->
|
||||||
|
ImageOverlay(
|
||||||
|
modifier =
|
||||||
|
contentModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(AppColors.TransparentBlack50),
|
||||||
|
onDismiss = { showOverlay = false },
|
||||||
|
player = player,
|
||||||
|
slideshowControls = slideshowControls,
|
||||||
|
slideshowEnabled = slideshowState.enabled,
|
||||||
|
image = imageState,
|
||||||
|
position = position,
|
||||||
|
count = pager?.size ?: -1,
|
||||||
|
onClickItem = {},
|
||||||
|
onLongClickItem = {},
|
||||||
|
onZoom = ::zoom,
|
||||||
|
onRotate = { rotation += it },
|
||||||
|
onReset = { reset(true) },
|
||||||
|
onShowFilterDialogClick = {
|
||||||
|
showFilterDialog = true
|
||||||
|
showOverlay = false
|
||||||
|
viewModel.pauseSlideshow()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(showFilterDialog) {
|
||||||
|
ImageFilterDialog(
|
||||||
|
filter = imageFilter,
|
||||||
|
showVideoOptions = false,
|
||||||
|
showSaveGalleryButton = true,
|
||||||
|
onChange = viewModel::updateImageFilter,
|
||||||
|
onClickSave = viewModel::saveImageFilter,
|
||||||
|
onClickSaveGallery = viewModel::saveGalleryFilter,
|
||||||
|
onDismissRequest = {
|
||||||
|
showFilterDialog = false
|
||||||
|
viewModel.unpauseSlideshow()
|
||||||
|
viewModel.pulseSlideshow()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.slideshow
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.map
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||||
|
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.properties.Delegates
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class)
|
||||||
|
class SlideshowViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val playerFactory: PlayerFactory,
|
||||||
|
private val playbackEffectDao: PlaybackEffectDao,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
@Assisted val slideshowSettings: Destination.Slideshow,
|
||||||
|
) : ViewModel(),
|
||||||
|
Player.Listener {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val player by lazy {
|
||||||
|
playerFactory.createVideoPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saveFilters = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether slideshow mode is on or off
|
||||||
|
*/
|
||||||
|
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
|
||||||
|
val slideshow: StateFlow<SlideshowState> = _slideshow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
|
||||||
|
*/
|
||||||
|
val slideshowActive = slideshow.map { it.enabled && !it.paused }
|
||||||
|
|
||||||
|
var slideshowDelay by Delegates.notNull<Long>()
|
||||||
|
|
||||||
|
// private val album = MutableLiveData<BaseItem>()
|
||||||
|
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
|
||||||
|
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
|
||||||
|
val position = MutableLiveData(0)
|
||||||
|
|
||||||
|
private val _image = MutableLiveData<ImageState>()
|
||||||
|
val image: LiveData<ImageState> = _image
|
||||||
|
|
||||||
|
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
||||||
|
private val _imageFilter = MutableLiveData(VideoFilter())
|
||||||
|
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
||||||
|
|
||||||
|
private var albumImageFilter = VideoFilter()
|
||||||
|
|
||||||
|
init {
|
||||||
|
addCloseable {
|
||||||
|
player.removeListener(this@SlideshowViewModel)
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
player.addListener(this@SlideshowViewModel)
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences
|
||||||
|
slideshowDelay =
|
||||||
|
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
||||||
|
?: AppPreference.SlideshowDuration.defaultValue
|
||||||
|
// val album =
|
||||||
|
// api.userLibraryApi
|
||||||
|
// .getItem(
|
||||||
|
// itemId = slideshowSettings.parentId,
|
||||||
|
// ).content
|
||||||
|
// .let { BaseItem(it, false) }
|
||||||
|
// this@SlideshowViewModel.album.setValueOnMain(album)
|
||||||
|
val includeItemTypes =
|
||||||
|
if (photoPrefs.slideshowPlayVideos) {
|
||||||
|
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
||||||
|
} else {
|
||||||
|
listOf(BaseItemKind.PHOTO)
|
||||||
|
}
|
||||||
|
val request =
|
||||||
|
slideshowSettings.filter.filter.applyTo(
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = slideshowSettings.parentId,
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
fields = PhotoItemFields,
|
||||||
|
recursive = true,
|
||||||
|
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
||||||
|
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
val filter =
|
||||||
|
playbackEffectDao
|
||||||
|
.getPlaybackEffect(
|
||||||
|
user.rowId,
|
||||||
|
slideshowSettings.parentId,
|
||||||
|
BaseItemKind.PHOTO_ALBUM,
|
||||||
|
)?.videoFilter
|
||||||
|
if (filter != null) {
|
||||||
|
Timber.v("Got filter for album %s", slideshowSettings.parentId)
|
||||||
|
albumImageFilter = filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pager =
|
||||||
|
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||||
|
.init(slideshowSettings.index)
|
||||||
|
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
||||||
|
updatePosition(slideshowSettings.index)?.join()
|
||||||
|
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextImage(): Boolean {
|
||||||
|
val size = pager.value?.size
|
||||||
|
val newPosition = position.value!! + 1
|
||||||
|
return if (size != null && newPosition < size) {
|
||||||
|
updatePosition(newPosition)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun previousImage(): Boolean {
|
||||||
|
val newPosition = position.value!! - 1
|
||||||
|
return if (newPosition >= 0) {
|
||||||
|
updatePosition(newPosition)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePosition(position: Int): Job? =
|
||||||
|
_pager.value?.let { pager ->
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val image = pager.getBlocking(position)
|
||||||
|
Timber.v("Got image for $position: ${image != null}")
|
||||||
|
if (image != null) {
|
||||||
|
this@SlideshowViewModel.position.setValueOnMain(position)
|
||||||
|
|
||||||
|
val url =
|
||||||
|
if (image.data.mediaType == MediaType.VIDEO) {
|
||||||
|
// TODO this assumes direct play
|
||||||
|
api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = image.id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
api.libraryApi.getDownloadUrl(image.id)
|
||||||
|
}
|
||||||
|
val chosenStreams =
|
||||||
|
if (image.data.mediaType == MediaType.VIDEO) {
|
||||||
|
image.data.mediaSources?.firstOrNull()?.let { source ->
|
||||||
|
val video =
|
||||||
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
|
val audio =
|
||||||
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||||
|
ChosenStreams(
|
||||||
|
itemPlayback = null,
|
||||||
|
plc = null,
|
||||||
|
itemId = image.id,
|
||||||
|
source = source,
|
||||||
|
videoStream = video,
|
||||||
|
audioStream = audio,
|
||||||
|
subtitleStream = null,
|
||||||
|
subtitlesDisabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageState =
|
||||||
|
ImageState(
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
imageUrlService.getItemImageUrl(image, ImageType.THUMB),
|
||||||
|
chosenStreams,
|
||||||
|
)
|
||||||
|
// reset image filter
|
||||||
|
updateImageFilter(albumImageFilter)
|
||||||
|
if (saveFilters) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
val vf =
|
||||||
|
playbackEffectDao
|
||||||
|
.getPlaybackEffect(
|
||||||
|
user.rowId,
|
||||||
|
image.id,
|
||||||
|
BaseItemKind.PHOTO,
|
||||||
|
)
|
||||||
|
if (vf != null && vf.videoFilter.hasImageFilter()) {
|
||||||
|
Timber.d(
|
||||||
|
"Loaded VideoFilter for image ${image.id}",
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
// Pause throttling so that the image loads with the filter applied immediately
|
||||||
|
imageFilter.stopThrottling(true)
|
||||||
|
updateImageFilter(vf.videoFilter)
|
||||||
|
imageFilter.startThrottling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_image.value = imageState
|
||||||
|
loadingState.value =
|
||||||
|
ImageLoadingState.Success(imageState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_image.value = imageState
|
||||||
|
loadingState.value = ImageLoadingState.Success(imageState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex)
|
||||||
|
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var slideshowJob: Job? = null
|
||||||
|
|
||||||
|
fun startSlideshow() {
|
||||||
|
_slideshow.update {
|
||||||
|
SlideshowState(enabled = true, paused = false)
|
||||||
|
}
|
||||||
|
if (_image.value
|
||||||
|
?.image
|
||||||
|
?.data
|
||||||
|
?.mediaType != MediaType.VIDEO
|
||||||
|
) {
|
||||||
|
pulseSlideshow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSlideshow() {
|
||||||
|
slideshowJob?.cancel()
|
||||||
|
_slideshow.update {
|
||||||
|
SlideshowState(enabled = false, paused = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseSlideshow() {
|
||||||
|
Timber.v("pauseSlideshow")
|
||||||
|
_slideshow.update {
|
||||||
|
if (it.enabled) {
|
||||||
|
slideshowJob?.cancel()
|
||||||
|
it.copy(paused = true)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpauseSlideshow() {
|
||||||
|
Timber.v("unpauseSlideshow")
|
||||||
|
_slideshow.update {
|
||||||
|
if (it.enabled) {
|
||||||
|
it.copy(paused = false)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pulseSlideshow() = pulseSlideshow(slideshowDelay)
|
||||||
|
|
||||||
|
fun pulseSlideshow(milliseconds: Long) {
|
||||||
|
Timber.v("pulseSlideshow $milliseconds")
|
||||||
|
slideshowJob?.cancel()
|
||||||
|
slideshowJob =
|
||||||
|
viewModelScope
|
||||||
|
.launchIO {
|
||||||
|
delay(milliseconds)
|
||||||
|
// Timber.v("pulseSlideshow after delay")
|
||||||
|
if (slideshowActive.first()) {
|
||||||
|
// Next image or loop to beginning
|
||||||
|
if (!nextImage()) updatePosition(0)
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateImageFilter(newFilter: VideoFilter) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
_imageFilter.setValueOnMain(newFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveImageFilter() {
|
||||||
|
image.value?.let {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val vf = _imageFilter.value
|
||||||
|
if (vf != null) {
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
playbackEffectDao
|
||||||
|
.insert(
|
||||||
|
PlaybackEffect(
|
||||||
|
user.rowId,
|
||||||
|
it.image.id,
|
||||||
|
BaseItemKind.PHOTO,
|
||||||
|
vf,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Timber.d("Saved VideoFilter for image %s", it.image.id)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showToast(
|
||||||
|
context,
|
||||||
|
"Saved",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveGalleryFilter() {
|
||||||
|
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
|
val vf = _imageFilter.value
|
||||||
|
if (vf != null) {
|
||||||
|
albumImageFilter = vf
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
playbackEffectDao
|
||||||
|
.insert(
|
||||||
|
PlaybackEffect(
|
||||||
|
user.rowId,
|
||||||
|
slideshowSettings.parentId,
|
||||||
|
BaseItemKind.PHOTO_ALBUM,
|
||||||
|
vf,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showToast(
|
||||||
|
context,
|
||||||
|
"Saved",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
pulseSlideshow(slideshowDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlideshowControls {
|
||||||
|
fun startSlideshow()
|
||||||
|
|
||||||
|
fun stopSlideshow()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ImageLoadingState {
|
||||||
|
data object Loading : ImageLoadingState()
|
||||||
|
|
||||||
|
data object Error : ImageLoadingState()
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val image: ImageState,
|
||||||
|
) : ImageLoadingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class ImageState(
|
||||||
|
val image: BaseItem,
|
||||||
|
val url: String,
|
||||||
|
val thumbnailUrl: String?,
|
||||||
|
val chosenStreams: ChosenStreams?,
|
||||||
|
) {
|
||||||
|
val id: UUID get() = image.id
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SlideshowState(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val paused: Boolean,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.util
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MediatorLiveData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiveData throttling value emissions so they don't happen more often than [delayMs].
|
||||||
|
*
|
||||||
|
* From https://stackoverflow.com/a/62467521
|
||||||
|
*/
|
||||||
|
class ThrottledLiveData<T>(
|
||||||
|
source: LiveData<T>,
|
||||||
|
delayMs: Long,
|
||||||
|
) : MediatorLiveData<T>() {
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
var delayMs = delayMs
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var isValueDelayed = false
|
||||||
|
private var delayedValue: T? = null
|
||||||
|
private var delayRunnable: Runnable? = null
|
||||||
|
set(value) {
|
||||||
|
field?.let { handler.removeCallbacks(it) }
|
||||||
|
value?.let { handler.postDelayed(it, delayMs) }
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() }
|
||||||
|
|
||||||
|
init {
|
||||||
|
addSource(source) { newValue ->
|
||||||
|
if (delayRunnable == null) {
|
||||||
|
value = newValue
|
||||||
|
startDelay()
|
||||||
|
} else {
|
||||||
|
isValueDelayed = true
|
||||||
|
delayedValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */
|
||||||
|
fun startThrottling(newDelay: Long = 0L) {
|
||||||
|
require(newDelay >= 0L)
|
||||||
|
when {
|
||||||
|
newDelay > 0 -> delayMs = newDelay
|
||||||
|
delayMs < 0 -> delayMs *= -1
|
||||||
|
delayMs > 0 -> return
|
||||||
|
else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop throttling, if [immediate] emit any pending value now. */
|
||||||
|
fun stopThrottling(immediate: Boolean = false) {
|
||||||
|
if (delayMs <= 0) return
|
||||||
|
delayMs *= -1
|
||||||
|
if (immediate) consumeDelayedValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInactive() {
|
||||||
|
super.onInactive()
|
||||||
|
consumeDelayedValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// start counting the delay or clear it if conditions are not met
|
||||||
|
private fun startDelay() {
|
||||||
|
delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consumeDelayedValue(): Boolean {
|
||||||
|
delayRunnable = null
|
||||||
|
return if (isValueDelayed) {
|
||||||
|
value = delayedValue
|
||||||
|
delayedValue = null
|
||||||
|
isValueDelayed = false
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -160,6 +160,11 @@ message AdvancedPreferences {
|
||||||
int64 image_disk_cache_size_bytes = 1;
|
int64 image_disk_cache_size_bytes = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message PhotoPreferences{
|
||||||
|
int64 slideshow_duration = 1;
|
||||||
|
bool slideshow_play_videos = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message AppPreferences {
|
message AppPreferences {
|
||||||
// The currently signed in server and user IDs, mostly for restoring a session
|
// The currently signed in server and user IDs, mostly for restoring a session
|
||||||
string current_server_id = 1;
|
string current_server_id = 1;
|
||||||
|
|
@ -175,4 +180,5 @@ message AppPreferences {
|
||||||
bool debug_logging = 9;
|
bool debug_logging = 9;
|
||||||
AdvancedPreferences advanced_preferences = 10;
|
AdvancedPreferences advanced_preferences = 10;
|
||||||
bool sign_in_automatically = 11;
|
bool sign_in_automatically = 11;
|
||||||
|
PhotoPreferences photo_preferences = 12;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<string name="fa_images" translatable="false"></string>
|
<string name="fa_images" translatable="false"></string>
|
||||||
<string name="fa_rotate_right" translatable="false"></string>
|
<string name="fa_rotate_right" translatable="false"></string>
|
||||||
<string name="fa_rotate_left" translatable="false"></string>
|
<string name="fa_rotate_left" translatable="false"></string>
|
||||||
|
<string name="fa_arrows_rotate" translatable="false"></string>
|
||||||
<string name="fa_arrow_right_arrow_left" translatable="false"></string>
|
<string name="fa_arrow_right_arrow_left" translatable="false"></string>
|
||||||
<string name="fa_magnifying_glass_plus" translatable="false"></string>
|
<string name="fa_magnifying_glass_plus" translatable="false"></string>
|
||||||
<string name="fa_magnifying_glass_minus" translatable="false"></string>
|
<string name="fa_magnifying_glass_minus" translatable="false"></string>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<string name="confirm">Confirm</string>
|
<string name="confirm">Confirm</string>
|
||||||
<string name="continue_watching">Continue watching</string>
|
<string name="continue_watching">Continue watching</string>
|
||||||
<string name="critic_rating">Critic Rating</string>
|
<string name="critic_rating">Critic Rating</string>
|
||||||
<string name="decimal_seconds">%.1f seconds</string>
|
<string name="decimal_seconds">%.2f seconds</string>
|
||||||
<string name="default_track">Default</string>
|
<string name="default_track">Default</string>
|
||||||
<string name="delete">Delete</string>
|
<string name="delete">Delete</string>
|
||||||
<string name="died">Died</string>
|
<string name="died">Died</string>
|
||||||
|
|
@ -465,6 +465,26 @@
|
||||||
<string name="hdr_subtitle_style">HDR subtitle style</string>
|
<string name="hdr_subtitle_style">HDR subtitle style</string>
|
||||||
<string name="image_subtitle_opacity">Image subtitle opacity</string>
|
<string name="image_subtitle_opacity">Image subtitle opacity</string>
|
||||||
|
|
||||||
|
<string name="brightness">Brightness</string>
|
||||||
|
<string name="contrast">Constrast</string>
|
||||||
|
<string name="saturation">Saturation</string>
|
||||||
|
<string name="hue">Hue</string>
|
||||||
|
<string name="red">Red</string>
|
||||||
|
<string name="green">Green</string>
|
||||||
|
<string name="blue">Blue</string>
|
||||||
|
<string name="blur">Blur</string>
|
||||||
|
<string name="save_for_album">Save for album</string>
|
||||||
|
<string name="play_slideshow">Play slideshow</string>
|
||||||
|
<string name="stop_slideshow">Stop slideshow</string>
|
||||||
|
<string name="slideshow_at_beginning">At beginning</string>
|
||||||
|
<string name="no_more_images">No more photos</string>
|
||||||
|
<string name="rotate_left">Rotate left</string>
|
||||||
|
<string name="rotate_right">Rotate right</string>
|
||||||
|
<string name="zoom_in">Zoom in</string>
|
||||||
|
<string name="zoom_out">Zoom out</string>
|
||||||
|
<string name="slideshow_duration">Slideshow duration</string>
|
||||||
|
<string name="play_videos_during_slideshow">Play videos during slideshow</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
<item>Lowest</item>
|
<item>Lowest</item>
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls
|
||||||
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" }
|
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue