diff --git a/src/amf/amf_config.h b/src/amf/amf_config.h index 1867f475716..7f5c4d018b5 100644 --- a/src/amf/amf_config.h +++ b/src/amf/amf_config.h @@ -106,7 +106,26 @@ namespace amf { bool enable_ssim_feedback = false; // --- High Motion Quality Boost (encoder-level, separate from PA) --- + // Default nullopt = do not set the property, let the AMD driver decide + // (FFmpeg amfenc.c never sets this property either). Some AMD driver + // releases (e.g. Adrenalin 26.5.x on RDNA4) appear to expose latent VCN + // bugs when this is enabled, leading to encoder freezes after ~minutes. std::optional high_motion_quality_boost_enable; + + // --- Low Latency Mode (encoder-level) --- + // Default nullopt = do not set the property, let the driver default decide. + // Matches FFmpeg amfenc behavior (only set when user opts in or Smart + // Access Video is enabled). Streaming workloads usually want this true, + // but exposing it lets users disable it as a workaround for driver bugs. + std::optional lowlatency_mode; + + // --- Input Queue Size (async_depth) --- + // Default nullopt = do not set the property; AMD driver default ~16, + // matches FFmpeg amfenc default. Sunshine historically forced 1 for + // minimum latency, but that is the most fragile code path inside the + // driver. Users can opt-in to 1 for absolute lowest latency or larger + // values (4/8/16) as a workaround for driver freezes. + std::optional input_queue_size; }; } // namespace amf diff --git a/src/amf/amf_d3d11.cpp b/src/amf/amf_d3d11.cpp index afdf08b9158..cbe8948bc1c 100644 --- a/src/amf/amf_d3d11.cpp +++ b/src/amf/amf_d3d11.cpp @@ -148,8 +148,12 @@ namespace amf { if (config.preanalysis) encoder->SetProperty(AMF_VIDEO_ENCODER_PRE_ANALYSIS_ENABLE, !!(*config.preanalysis)); if (config.vbaq) encoder->SetProperty(AMF_VIDEO_ENCODER_ENABLE_VBAQ, !!(*config.vbaq)); encoder->SetProperty(AMF_VIDEO_ENCODER_B_PIC_PATTERN, (amf_int64) 0); - encoder->SetProperty(AMF_VIDEO_ENCODER_LOWLATENCY_MODE, true); - encoder->SetProperty(AMF_VIDEO_ENCODER_INPUT_QUEUE_SIZE, (amf_int64) 1); + // LOWLATENCY_MODE and INPUT_QUEUE_SIZE: only set when user opts in. + // Matches FFmpeg amfenc behavior (FFmpeg never forces these properties). + // Forcing them to true/1 has been observed to expose latent AMD driver + // bugs (see AlkaidLab/foundation-sunshine#666 freeze on RDNA4 26.5.x). + if (config.lowlatency_mode) encoder->SetProperty(AMF_VIDEO_ENCODER_LOWLATENCY_MODE, !!(*config.lowlatency_mode)); + if (config.input_queue_size) encoder->SetProperty(AMF_VIDEO_ENCODER_INPUT_QUEUE_SIZE, (amf_int64) *config.input_queue_size); encoder->SetProperty(AMF_VIDEO_ENCODER_QUERY_TIMEOUT, (amf_int64) 1); // LTR for RFI (Reference Frame Invalidation, weak-network recovery). @@ -222,8 +226,10 @@ namespace amf { encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_HEADER_INSERTION_MODE, (amf_int64) AMF_VIDEO_ENCODER_HEVC_HEADER_INSERTION_MODE_IDR_ALIGNED); if (config.preanalysis) encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_PRE_ANALYSIS_ENABLE, !!(*config.preanalysis)); if (config.vbaq) encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_ENABLE_VBAQ, !!(*config.vbaq)); - encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_LOWLATENCY_MODE, true); - encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_INPUT_QUEUE_SIZE, (amf_int64) 1); + // LOWLATENCY_MODE and INPUT_QUEUE_SIZE: only set when user opts in. + // See H.264 block above for rationale (FFmpeg-aligned default behavior). + if (config.lowlatency_mode) encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_LOWLATENCY_MODE, !!(*config.lowlatency_mode)); + if (config.input_queue_size) encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_INPUT_QUEUE_SIZE, (amf_int64) *config.input_queue_size); encoder->SetProperty(AMF_VIDEO_ENCODER_HEVC_QUERY_TIMEOUT, (amf_int64) 1); if (colorspace.bit_depth == 10) { @@ -286,14 +292,15 @@ namespace amf { encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_ALIGNMENT_MODE, (amf_int64) AMF_VIDEO_ENCODER_AV1_ALIGNMENT_MODE_NO_RESTRICTIONS); encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_GOP_SIZE, (amf_int64) 0); if (config.preanalysis) encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_PRE_ANALYSIS_ENABLE, !!(*config.preanalysis)); - encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_INPUT_QUEUE_SIZE, (amf_int64) 1); + // INPUT_QUEUE_SIZE / ENCODING_LATENCY_MODE: only set when user opts in. + // Matches FFmpeg amfenc behavior (never auto-forces LOWEST_LATENCY). + // See AlkaidLab/foundation-sunshine#666 for the RDNA4 freeze that + // motivated stopping aggressive defaults. + if (config.input_queue_size) encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_INPUT_QUEUE_SIZE, (amf_int64) *config.input_queue_size); encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_QUERY_TIMEOUT, (amf_int64) 1); if (config.av1_encoding_latency_mode) { encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_ENCODING_LATENCY_MODE, (amf_int64) *config.av1_encoding_latency_mode); } - else { - encoder->SetProperty(AMF_VIDEO_ENCODER_AV1_ENCODING_LATENCY_MODE, (amf_int64) AMF_VIDEO_ENCODER_AV1_ENCODING_LATENCY_MODE_LOWEST_LATENCY); - } // AV1 Screen Content Tools if (config.av1_screen_content_tools) { diff --git a/src/config.cpp b/src/config.cpp index b9b08e07413..ec8c8b85cff 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -867,6 +867,33 @@ namespace config { input = to_bool(tmp); } + void + bool_f(std::unordered_map &vars, const std::string &name, std::optional &input) { + std::string tmp; + string_f(vars, name, tmp); + + if (tmp.empty()) { + return; + } + + input = to_bool(tmp); + } + + void + int_between_f(std::unordered_map &vars, const std::string &name, std::optional &input, const std::pair &range) { + std::optional temp; + int_f(vars, name, temp); + + if (!temp) { + return; + } + + TUPLE_2D_REF(lower, upper, range); + if (*temp >= lower && *temp <= upper) { + input = *temp; + } + } + void double_f(std::unordered_map &vars, const std::string &name, double &input) { std::string tmp; @@ -1188,6 +1215,15 @@ namespace config { int_between_f(vars, "amd_qvbr_quality", video.amd.amd_qvbr_quality, { 1, 51 }); int_between_f(vars, "amd_ltr_frames", video.amd.amd_ltr_frames, { 0, 4 }); int_between_f(vars, "amd_slices_per_frame", video.amd.amd_slices_per_frame, { 0, 4 }); + // FFmpeg-aligned opt-in toggles (default nullopt = let AMD driver decide, + // matches FFmpeg amfenc.c behavior of never setting the property unless + // the user explicitly opts in). See AlkaidLab/foundation-sunshine#666 for + // the RDNA4 freeze that motivated removing aggressive defaults. + bool_f(vars, "amd_high_motion_qb", video.amd.amd_high_motion_qb); + bool_f(vars, "amd_lowlatency_mode", video.amd.amd_lowlatency_mode); + int_between_f(vars, "amd_input_queue_size", video.amd.amd_input_queue_size, { 1, 16 }); + // AMF_VIDEO_ENCODER_AV1_ENCODING_LATENCY_MODE_* enum: 0=NONE, 1=POWER_SAVING_REAL_TIME, 2=REAL_TIME, 3=LOWEST_LATENCY + int_between_f(vars, "amd_av1_latency_mode", video.amd.amd_av1_latency_mode, { 0, 3 }); int_f(vars, "vt_coder", video.vt.vt_coder, vt::coder_from_view); int_f(vars, "vt_software", video.vt.vt_allow_sw, vt::allow_software_from_view); diff --git a/src/config.h b/src/config.h index c1bdd87bf71..2dd5cd8cab1 100644 --- a/src/config.h +++ b/src/config.h @@ -75,6 +75,17 @@ namespace config { int amd_qvbr_quality = 23; // QVBR quality level 1-51 (lower=better, default=23) int amd_ltr_frames = 0; // LTR frames for RFI (0=disabled by default; matches FFmpeg amfenc behavior to avoid static-region color blocks) int amd_slices_per_frame = 0; // Slices/tiles per frame (0=client decides, 1-4=minimum) + // The properties below historically had aggressive hardcoded defaults that + // forced AMF code paths FFmpeg never touches (HIGH_MOTION_QUALITY_BOOST=on, + // INPUT_QUEUE_SIZE=1, LOWLATENCY_MODE=on, AV1 LOWEST_LATENCY). Those paths + // expose latent AMD driver bugs (e.g. RDNA4 Adrenalin 26.5.x freeze after + // ~minutes, AlkaidLab/foundation-sunshine#666). Default is nullopt = + // "do not call SetProperty" so the driver picks its own default, matching + // FFmpeg amfenc behavior. Users can still opt in via the WebUI. + std::optional amd_high_motion_qb; + std::optional amd_lowlatency_mode; + std::optional amd_input_queue_size; // 1-16 + std::optional amd_av1_latency_mode; // AMF_VIDEO_ENCODER_AV1_ENCODING_LATENCY_MODE_* } amd; struct { diff --git a/src/confighttp.cpp b/src/confighttp.cpp index e5ad3b708c4..0249df94462 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -133,6 +133,49 @@ namespace confighttp { response->write(output_tree.dump(), headers); } + /** + * @brief Validate the request content type and send bad request when mismatch. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param contentType The expected content type. + * @return true if the request's Content-Type matches the expected type. + * + * Backport of upstream LizardByte/Sunshine 738ac93a0ec1 (CVE-2025-53095). + * AlkaidLab does not have upstream's bad_request() helper, so the bad-request + * response is inlined here. + */ + bool check_content_type(resp_https_t response, req_https_t request, const std::string &contentType) { + auto send_bad_request = [response](const std::string &error_message) { + nlohmann::json tree; + tree["status_code"] = 400; + tree["status"] = false; + tree["error"] = error_message; + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + response->write(SimpleWeb::StatusCode::client_error_bad_request, tree.dump(), headers); + }; + auto requestContentType = request->header.find("content-type"); + if (requestContentType == request->header.end()) { + send_bad_request("Content type not provided"); + return false; + } + // Extract the media type part before any parameters (e.g., charset) + std::string actualContentType = requestContentType->second; + size_t semicolonPos = actualContentType.find(';'); + if (semicolonPos != std::string::npos) { + actualContentType = actualContentType.substr(0, semicolonPos); + } + boost::algorithm::trim(actualContentType); + boost::algorithm::to_lower(actualContentType); + std::string expectedContentType(contentType); + boost::algorithm::to_lower(expectedContentType); + if (actualContentType != expectedContentType) { + send_bad_request("Content type mismatch"); + return false; + } + return true; + } + void send_unauthorized(resp_https_t response, req_https_t request) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); @@ -728,6 +771,7 @@ namespace confighttp { void saveApp(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!authenticate(response, request)) return; print_req(request); @@ -889,8 +933,168 @@ namespace confighttp { proc::refresh(config::stream.file_apps); } + /** + * @brief Delete multiple apps in a single atomic operation. + * + * Expects JSON body: {"indices":[, , ...]} + * + * Rationale: clients that want to delete N apps cannot just loop on + * DELETE /api/apps/{id} — each successful delete shifts the remaining + * indices, and a second concurrent caller is rejected by apps_writing. + * By taking the lock once and rebuilding the array under it, we avoid both + * problems: indices are interpreted against a single snapshot, the JSON file + * is rewritten once, and proc::refresh runs only once. + * + * Response: + * 200 {"status":"true", "deleted":, "remaining":} + * 400 {"status":"false", "error":"..."} for content-type / JSON / index / + * file-write business errors. Follows uploadCover() convention in + * this same file (status code derived from presence of 'error' key + * inside the fail_guard). + * 401 not authenticated (via authenticate) + * 409 another apps-writer is already in flight + * + * @api_examples{/api/apps/batch-delete| POST| {"indices":[2,5,7]}} + */ + void + batchDeleteApps(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; + if (!authenticate(response, request)) return; + + print_req(request); + + // Same single-writer guard as deleteApp / saveApp. + bool expected = false; + if (!apps_writing.compare_exchange_strong(expected, true)) { + pt::ptree busy; + busy.put("status", "false"); + busy.put("error", "Another operation is in progress"); + std::ostringstream data; + pt::write_json(data, busy); + response->write(SimpleWeb::StatusCode::client_error_conflict, data.str()); + return; + } + auto writing_guard = util::fail_guard([]() { apps_writing = false; }); + + pt::ptree outputTree; + auto g = util::fail_guard([&]() { + // Mirror uploadCover's convention in this file: when fail_guard fires + // with an 'error' field, emit 4xx so clients can distinguish business + // failures from success rather than parsing status:"false" out of a 200. + SimpleWeb::StatusCode code = SimpleWeb::StatusCode::success_ok; + if (outputTree.get_child_optional("error").has_value()) { + code = SimpleWeb::StatusCode::client_error_bad_request; + } + std::ostringstream data; + pt::write_json(data, outputTree); + response->write(code, data.str()); + }); + + std::stringstream ss; + ss << request->content.rdbuf(); + + std::set indices_to_remove; + try { + pt::ptree body; + pt::read_json(ss, body); + + auto indices_node = body.get_child_optional("indices"); + if (!indices_node) { + outputTree.put("status", "false"); + outputTree.put("error", "Missing 'indices' array"); + return; + } + + if (indices_node->size() > 1024) { + outputTree.put("status", "false"); + outputTree.put("error", "Too many indices in a single request"); + return; + } + for (const auto &kv : *indices_node) { + // boost::property_tree json reader stores array values as anonymous + // children with empty keys. get_value() will throw if the value + // is not parseable as an integer. + int idx = kv.second.get_value(); + indices_to_remove.insert(idx); + } + } + catch (std::exception &e) { + BOOST_LOG(warning) << "BatchDeleteApps: invalid JSON body: "sv << e.what(); + outputTree.put("status", "false"); + outputTree.put("error", "Invalid JSON body"); + return; + } + + pt::ptree fileTree; + try { + pt::read_json(config::stream.file_apps, fileTree); + } + catch (std::exception &e) { + BOOST_LOG(warning) << "BatchDeleteApps: "sv << e.what(); + outputTree.put("status", "false"); + outputTree.put("error", "Invalid File JSON"); + return; + } + + // Validate every index against the single snapshot we just loaded BEFORE + // any write, so a partially invalid request fails atomically. + const int apps_count = static_cast(fileTree.get_child("apps"s).size()); + for (int idx : indices_to_remove) { + if (idx < 0 || idx >= apps_count) { + outputTree.put("status", "false"); + outputTree.put("error", "Invalid Index"); + return; + } + } + + // Empty selection: success no-op. Skip the write+refresh, but still emit + // 'remaining' so the success contract matches the non-empty path. + if (indices_to_remove.empty()) { + outputTree.put("status", "true"); + outputTree.put("deleted", 0); + outputTree.put("remaining", apps_count); + return; + } + + int deleted_count = 0; + int remaining_count = 0; + try { + auto &apps_node = fileTree.get_child("apps"s); + pt::ptree newApps; + int i = 0; + for (const auto &kv : apps_node) { + if (indices_to_remove.find(i) == indices_to_remove.end()) { + newApps.push_back(std::make_pair("", kv.second)); + } + else { + ++deleted_count; + } + ++i; + } + remaining_count = static_cast(newApps.size()); + fileTree.erase("apps"); + fileTree.push_back(std::make_pair("apps", newApps)); + + pt::write_json(config::stream.file_apps, fileTree); + } + catch (std::exception &e) { + BOOST_LOG(warning) << "BatchDeleteApps: "sv << e.what(); + outputTree.put("status", "false"); + outputTree.put("error", "Invalid File JSON"); + return; + } + + BOOST_LOG(info) << "BatchDeleteApps: removed "sv << deleted_count + << " app(s), "sv << remaining_count << " remaining"sv; + outputTree.put("status", "true"); + outputTree.put("deleted", deleted_count); + outputTree.put("remaining", remaining_count); + proc::refresh(config::stream.file_apps); + } + void uploadCover(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!authenticate(response, request)) return; std::stringstream ss; @@ -1157,6 +1361,7 @@ namespace confighttp { void saveConfig(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!authenticate(response, request)) return; print_req(request); @@ -1255,6 +1460,7 @@ namespace confighttp { void savePassword(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!config::sunshine.username.empty() && !authenticate(response, request)) return; print_req(request); @@ -1325,6 +1531,7 @@ namespace confighttp { void savePin(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!authenticate(response, request)) return; print_req(request); @@ -1560,6 +1767,7 @@ namespace confighttp { void unpair(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) return; if (!authenticate(response, request)) return; print_req(request); @@ -2775,6 +2983,7 @@ namespace confighttp { server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence; server.resource["^/api/password$"]["POST"] = savePassword; server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; + server.resource["^/api/apps/batch-delete$"]["POST"] = batchDeleteApps; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/list$"]["GET"] = listClients; server.resource["^/api/clients/list$"]["POST"] = saveConfig; diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index 3a55c1da6cd..6d93abc85fa 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -2107,8 +2107,18 @@ namespace platf::dxgi { amf_cfg.pa_high_motion_quality_boost = 1; // Auto } - // High motion quality boost at encoder level - amf_cfg.high_motion_quality_boost_enable = true; + // High motion quality boost: opt-in only. Default nullopt = do not call + // SetProperty, let the AMD driver pick its default (FFmpeg-aligned). + // Forcing this on unconditionally was found to expose driver bugs on + // RDNA4 + Adrenalin 26.5.x (AlkaidLab/foundation-sunshine#666). + amf_cfg.high_motion_quality_boost_enable = config::video.amd.amd_high_motion_qb; + + // Low latency mode / input queue size / AV1 encoding latency mode: + // also opt-in to match FFmpeg amfenc behavior. Default nullopt = + // do not SetProperty, driver picks the default code path. + amf_cfg.lowlatency_mode = config::video.amd.amd_lowlatency_mode; + amf_cfg.input_queue_size = config::video.amd.amd_input_queue_size; + amf_cfg.av1_encoding_latency_mode = config::video.amd.amd_av1_latency_mode; // Apply server-side slices per frame override if configured auto effective_config = client_config; diff --git a/src_assets/common/assets/web/composables/useApps.js b/src_assets/common/assets/web/composables/useApps.js index fac1372ceda..763e83e7d29 100644 --- a/src_assets/common/assets/web/composables/useApps.js +++ b/src_assets/common/assets/web/composables/useApps.js @@ -33,6 +33,14 @@ export function useApps() { const selectedAppType = ref('all') // 'all', 'executable', 'shortcut', 'batch', 'command', 'url' const deleteConfirmIndex = ref(null) + // 批量删除:selectionMode 仅控制 UI 是否显示多选 checkbox; + // selectedIndices 是 Set,存的是 apps.value 的原始 index, + // 不能存 filteredApps 的下标,否则搜索后下标会错位。 + const selectionMode = ref(false) + const selectedIndices = ref(new Set()) + const batchDeleteConfirm = ref(false) + const isBatchDeleting = ref(false) + // 计算属性 const messageClass = computed(() => ({ [`alert-${messageType.value}`]: true, @@ -62,8 +70,11 @@ export function useApps() { ...overrides, }) + let translate = (key, params) => (params ? `${key} ${JSON.stringify(params)}` : key) + // 初始化 const init = (t) => { + translate = t envVars.value = Object.fromEntries( Object.entries(ENV_VARS_CONFIG).map(([key, translationKey]) => [key, t(translationKey)]) ) @@ -161,6 +172,74 @@ export function useApps() { } } + // 进入/退出多选模式,退出时清空选择 + const toggleSelectionMode = () => { + selectionMode.value = !selectionMode.value + if (!selectionMode.value) { + selectedIndices.value = new Set() + } + } + + const toggleAppSelection = (index) => { + const next = new Set(selectedIndices.value) + if (next.has(index)) next.delete(index) + else next.add(index) + selectedIndices.value = next + } + + const isAppSelected = (index) => selectedIndices.value.has(index) + + // 全选/反选 + const selectAllFiltered = () => { + const next = new Set(selectedIndices.value) + filteredApps.value.forEach((app) => { + const i = apps.value.indexOf(app) + if (i >= 0) next.add(i) + }) + selectedIndices.value = next + } + + const clearSelection = () => { + selectedIndices.value = new Set() + } + + const askBatchDelete = () => { + if (selectedIndices.value.size === 0) return + batchDeleteConfirm.value = true + } + + const cancelBatchDelete = () => { + batchDeleteConfirm.value = false + } + + const confirmBatchDelete = async () => { + const indices = Array.from(selectedIndices.value) + if (indices.length === 0) { + batchDeleteConfirm.value = false + return + } + try { + isBatchDeleting.value = true + const result = await AppService.batchDeleteApps(indices) + await loadApps() + selectedIndices.value = new Set() + selectionMode.value = false + batchDeleteConfirm.value = false + showMessage( + translate('apps.batch_delete_result', { deleted: result.deleted, remaining: result.remaining }), + APP_CONSTANTS.MESSAGE_TYPES.SUCCESS + ) + } catch (error) { + console.error('批量删除失败:', error) + showMessage( + error?.message || translate('apps.batch_delete_failed'), + APP_CONSTANTS.MESSAGE_TYPES.ERROR + ) + } finally { + isBatchDeleting.value = false + } + } + // 检测是否有未保存的更改 const hasUnsavedChanges = () => { if (apps.value.length !== originalApps.value.length) { @@ -533,6 +612,10 @@ export function useApps() { scannedAppsSearchQuery, showGamesOnly, selectedAppType, + selectionMode, + selectedIndices, + batchDeleteConfirm, + isBatchDeleting, // 计算属性 messageClass, filteredScannedApps, @@ -553,6 +636,14 @@ export function useApps() { cancelDeleteApp, confirmDeleteApp, deleteConfirmIndex, + toggleSelectionMode, + toggleAppSelection, + isAppSelected, + selectAllFiltered, + clearSelection, + askBatchDelete, + cancelBatchDelete, + confirmBatchDelete, save, hasUnsavedChanges, onDragStart, diff --git a/src_assets/common/assets/web/composables/useConfig.js b/src_assets/common/assets/web/composables/useConfig.js index 3fe249df7f8..e4ff638ec57 100644 --- a/src_assets/common/assets/web/composables/useConfig.js +++ b/src_assets/common/assets/web/composables/useConfig.js @@ -168,6 +168,13 @@ const DEFAULT_TABS = [ amd_preanalysis: 'disabled', amd_vbaq: 'enabled', amd_coder: 'auto', + // AMF advanced (driver workarounds): empty string = driver default + // (FFmpeg-aligned). Users can override per-property if troubleshooting + // freezes or tuning latency. See issue #666 (RDNA4 26.5.x). + amd_high_motion_qb: '', + amd_lowlatency_mode: '', + amd_input_queue_size: '', + amd_av1_latency_mode: '', }, }, { diff --git a/src_assets/common/assets/web/configs/tabs/encoders/AmdAmfEncoder.vue b/src_assets/common/assets/web/configs/tabs/encoders/AmdAmfEncoder.vue index 6ab910ffe8a..cb76cbb23e0 100644 --- a/src_assets/common/assets/web/configs/tabs/encoders/AmdAmfEncoder.vue +++ b/src_assets/common/assets/web/configs/tabs/encoders/AmdAmfEncoder.vue @@ -163,6 +163,93 @@ const config = ref(props.config)
{{ $t('config.amd_slices_per_frame_desc') }}
+ + +
+
+

+ +

+
+
+
{{ $t('config.amd_advanced_group_desc') }}
+ + + + +
+ + +
{{ $t('config.amd_high_motion_qb_desc') }}
+
+ + +
+ + +
{{ $t('config.amd_lowlatency_mode_desc') }}
+
+ + +
+ + +
{{ $t('config.amd_input_queue_size_desc') }}
+
+ + +
+ + +
{{ $t('config.amd_av1_latency_mode_desc') }}
+
+
+
+
+
diff --git a/src_assets/common/assets/web/public/assets/locale/bg.json b/src_assets/common/assets/web/public/assets/locale/bg.json index 37a4f1bccb7..1f0c19973b7 100644 --- a/src_assets/common/assets/web/public/assets/locale/bg.json +++ b/src_assets/common/assets/web/public/assets/locale/bg.json @@ -54,6 +54,14 @@ "covers_found": "Намерени обложки", "delete": "Изтриване", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Превключване на множествен избор", + "batch_selected": "Избрани: {count}", + "batch_select_all": "Избери всички филтрирани", + "batch_clear": "Изчисти избора", + "batch_delete": "Пакетно изтриване", + "batch_delete_confirm": "Да се изтрият ли {count} избрани приложения?", + "batch_delete_result": "Изтрити {deleted} приложения, останали {remaining}", + "batch_delete_failed": "Пакетното изтриване е неуспешно", "detached_cmds": "Разкачени команди", "detached_cmds_add": "Добавяне на разкачена команда", "detached_cmds_desc": "Списък с команди, които да се изпълняват във фонов режим.", diff --git a/src_assets/common/assets/web/public/assets/locale/cs.json b/src_assets/common/assets/web/public/assets/locale/cs.json index fb10bdd8dce..1b783706bae 100644 --- a/src_assets/common/assets/web/public/assets/locale/cs.json +++ b/src_assets/common/assets/web/public/assets/locale/cs.json @@ -54,6 +54,14 @@ "covers_found": "Nalezené obaly", "delete": "Vymazat", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Přepnout výběr více položek", + "batch_selected": "Vybráno: {count}", + "batch_select_all": "Vybrat všechny filtrované", + "batch_clear": "Zrušit výběr", + "batch_delete": "Hromadné odstranění", + "batch_delete_confirm": "Odstranit {count} vybraných aplikací?", + "batch_delete_result": "Odstraněno {deleted} aplikací, zbývá {remaining}", + "batch_delete_failed": "Hromadné odstranění se nezdařilo", "detached_cmds": "Oddělené příkazy", "detached_cmds_add": "Přidat oddělený příkaz", "detached_cmds_desc": "Seznam příkazů, které mají být spuštěny na pozadí.", diff --git a/src_assets/common/assets/web/public/assets/locale/de.json b/src_assets/common/assets/web/public/assets/locale/de.json index 7a2f98c78bb..34438b4bc1e 100644 --- a/src_assets/common/assets/web/public/assets/locale/de.json +++ b/src_assets/common/assets/web/public/assets/locale/de.json @@ -54,6 +54,14 @@ "covers_found": "Cover gefunden", "delete": "Löschen", "delete_confirm": "Möchten Sie \"{name}\" wirklich löschen?", + "batch_select_toggle": "Mehrfachauswahl umschalten", + "batch_selected": "{count} ausgewählt", + "batch_select_all": "Alle gefilterten auswählen", + "batch_clear": "Auswahl löschen", + "batch_delete": "Stapellöschung", + "batch_delete_confirm": "{count} ausgewählte Apps löschen?", + "batch_delete_result": "{deleted} Apps gelöscht, {remaining} verbleibend", + "batch_delete_failed": "Stapellöschung fehlgeschlagen", "detached_cmds": "Getrennte Befehle", "detached_cmds_add": "Separiertes Kommando hinzufügen", "detached_cmds_desc": "Eine Liste von Befehlen, die im Hintergrund ausgeführt werden sollen.", diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 2903b059fc5..3e65b29561a 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -54,6 +54,14 @@ "covers_found": "Covers Found", "delete": "Delete", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Toggle multi-select", + "batch_selected": "{count} selected", + "batch_select_all": "Select all filtered", + "batch_clear": "Clear selection", + "batch_delete": "Batch delete", + "batch_delete_confirm": "Delete {count} selected apps?", + "batch_delete_result": "Deleted {deleted} apps, {remaining} remaining", + "batch_delete_failed": "Batch delete failed", "detached_cmds": "Detached Commands", "detached_cmds_add": "Add Detached Command", "detached_cmds_desc": "A list of commands to be run in the background.", @@ -198,6 +206,21 @@ "amd_slices_per_frame": "AMF Slices Per Frame", "amd_slices_per_frame_auto": "Auto (client decides)", "amd_slices_per_frame_desc": "Splits each frame into multiple slices/tiles for parallel encoding and decoding, which can reduce latency. For H.264/HEVC this sets slices per frame; for AV1 this sets tiles per frame. Set to 0 to let the client decide.", + "amd_advanced_group": "AMF Advanced Tuning", + "amd_advanced_group_desc": "Optional driver-level AMF properties. Leaving everything blank keeps the AMD driver's default (matches FFmpeg amfenc and is recommended for most users). Tune these if you want to push for lower latency, or set them to 'disabled' to work around hardware/driver bugs such as the RX 9070 / RDNA4 + Adrenalin 26.5.x H.264/HEVC freeze (see issue #666).", + "amd_advanced_reset": "Reset all to driver default", + "amd_driver_default": "driver default (recommended)", + "amd_high_motion_qb": "AMF High Motion Quality Boost", + "amd_high_motion_qb_desc": "Encoder-level quality boost for fast-moving content. Leave blank to use the driver default. 'Enabled' was the legacy Sunshine behavior and may improve quality; 'Disabled' if you hit driver freezes.", + "amd_lowlatency_mode": "AMF Low Latency Mode", + "amd_lowlatency_mode_desc": "AMD VCN low-latency code path. Leave blank to use the driver default. 'Enabled' was the legacy Sunshine behavior and minimizes latency; 'Disabled' if you hit driver freezes (RX 9070 / RDNA4 H.264/HEVC).", + "amd_input_queue_size": "AMF Input Queue Size", + "amd_input_queue_size_desc": "Frames the encoder may buffer internally (1-16). Leave blank for driver default (FFmpeg uses 16). Sunshine's legacy value was 1 for minimum latency. Lower = lower latency, higher = more stable.", + "amd_av1_latency_mode": "AMF AV1 Encoding Latency Mode", + "amd_av1_latency_mode_power_saving": "power saving real time", + "amd_av1_latency_mode_real_time": "real time", + "amd_av1_latency_mode_lowest": "lowest latency", + "amd_av1_latency_mode_desc": "AV1-only latency mode. Leave blank for driver default. Sunshine's legacy value was 'lowest latency'. If unsure, keep the driver default.", "amd_usage": "AMF Usage", "amd_usage_desc": "This sets the base encoding profile. All options presented below will override a subset of the usage profile, but there are additional hidden settings applied that cannot be configured elsewhere.", "amd_usage_lowlatency": "lowlatency - low latency (fastest)", diff --git a/src_assets/common/assets/web/public/assets/locale/en_GB.json b/src_assets/common/assets/web/public/assets/locale/en_GB.json index 170f93915d1..4a74db9dc2c 100644 --- a/src_assets/common/assets/web/public/assets/locale/en_GB.json +++ b/src_assets/common/assets/web/public/assets/locale/en_GB.json @@ -54,6 +54,14 @@ "covers_found": "Covers Found", "delete": "Delete", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Toggle multi-select", + "batch_selected": "{count} selected", + "batch_select_all": "Select all filtered", + "batch_clear": "Clear selection", + "batch_delete": "Batch delete", + "batch_delete_confirm": "Delete {count} selected apps?", + "batch_delete_result": "Deleted {deleted} apps, {remaining} remaining", + "batch_delete_failed": "Batch delete failed", "detached_cmds": "Detached Commands", "detached_cmds_add": "Add Detached Command", "detached_cmds_desc": "A list of commands to be run in the background.", diff --git a/src_assets/common/assets/web/public/assets/locale/en_US.json b/src_assets/common/assets/web/public/assets/locale/en_US.json index 20d8116f285..4ef51d8572d 100644 --- a/src_assets/common/assets/web/public/assets/locale/en_US.json +++ b/src_assets/common/assets/web/public/assets/locale/en_US.json @@ -54,6 +54,14 @@ "covers_found": "Covers Found", "delete": "Delete", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Toggle multi-select", + "batch_selected": "{count} selected", + "batch_select_all": "Select all filtered", + "batch_clear": "Clear selection", + "batch_delete": "Batch delete", + "batch_delete_confirm": "Delete {count} selected apps?", + "batch_delete_result": "Deleted {deleted} apps, {remaining} remaining", + "batch_delete_failed": "Batch delete failed", "detached_cmds": "Detached Commands", "detached_cmds_add": "Add Detached Command", "detached_cmds_desc": "A list of commands to be run in the background.", diff --git a/src_assets/common/assets/web/public/assets/locale/es.json b/src_assets/common/assets/web/public/assets/locale/es.json index 492ea6a70c7..105a1ecb6fd 100644 --- a/src_assets/common/assets/web/public/assets/locale/es.json +++ b/src_assets/common/assets/web/public/assets/locale/es.json @@ -54,6 +54,14 @@ "covers_found": "Cubiertas encontradas", "delete": "Eliminar", "delete_confirm": "¿Está seguro de que desea eliminar \"{name}\"?", + "batch_select_toggle": "Alternar selección múltiple", + "batch_selected": "{count} seleccionados", + "batch_select_all": "Seleccionar todos los filtrados", + "batch_clear": "Borrar selección", + "batch_delete": "Eliminación por lotes", + "batch_delete_confirm": "¿Eliminar las {count} aplicaciones seleccionadas?", + "batch_delete_result": "Eliminadas {deleted} aplicaciones, quedan {remaining}", + "batch_delete_failed": "Error en la eliminación por lotes", "detached_cmds": "Comandos separados", "detached_cmds_add": "Añadir comando separado", "detached_cmds_desc": "Una lista de comandos a ejecutar en segundo plano.", diff --git a/src_assets/common/assets/web/public/assets/locale/fr.json b/src_assets/common/assets/web/public/assets/locale/fr.json index fcdcfa9343e..158a237a391 100644 --- a/src_assets/common/assets/web/public/assets/locale/fr.json +++ b/src_assets/common/assets/web/public/assets/locale/fr.json @@ -54,6 +54,14 @@ "covers_found": "Jaquettes trouvées", "delete": "Supprimer", "delete_confirm": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?", + "batch_select_toggle": "Basculer la sélection multiple", + "batch_selected": "{count} sélectionné(s)", + "batch_select_all": "Tout sélectionner (filtré)", + "batch_clear": "Effacer la sélection", + "batch_delete": "Suppression par lot", + "batch_delete_confirm": "Supprimer {count} applications sélectionnées ?", + "batch_delete_result": "{deleted} applications supprimées, {remaining} restantes", + "batch_delete_failed": "Échec de la suppression par lot", "detached_cmds": "Commandes détachées", "detached_cmds_add": "Ajouter une commande détachée", "detached_cmds_desc": "Une liste de commandes à exécuter en arrière-plan.", diff --git a/src_assets/common/assets/web/public/assets/locale/it.json b/src_assets/common/assets/web/public/assets/locale/it.json index 9fdcb1cf7a7..ef0f4e12ae2 100644 --- a/src_assets/common/assets/web/public/assets/locale/it.json +++ b/src_assets/common/assets/web/public/assets/locale/it.json @@ -54,6 +54,14 @@ "covers_found": "Copertine trovate", "delete": "Cancella", "delete_confirm": "Sei sicuro di voler eliminare \"{name}\"?", + "batch_select_toggle": "Attiva/disattiva selezione multipla", + "batch_selected": "{count} selezionati", + "batch_select_all": "Seleziona tutti i risultati filtrati", + "batch_clear": "Cancella selezione", + "batch_delete": "Eliminazione in blocco", + "batch_delete_confirm": "Eliminare le {count} app selezionate?", + "batch_delete_result": "Eliminate {deleted} app, {remaining} rimanenti", + "batch_delete_failed": "Eliminazione in blocco non riuscita", "detached_cmds": "Comandi Separati", "detached_cmds_add": "Aggiungi comando separato", "detached_cmds_desc": "Un elenco di comandi da eseguire in background.", diff --git a/src_assets/common/assets/web/public/assets/locale/ja.json b/src_assets/common/assets/web/public/assets/locale/ja.json index a5a515ab201..bfd9bd0cdc0 100644 --- a/src_assets/common/assets/web/public/assets/locale/ja.json +++ b/src_assets/common/assets/web/public/assets/locale/ja.json @@ -54,6 +54,14 @@ "covers_found": "カバー画像が見つかりました", "delete": "削除", "delete_confirm": "\"{name}\" を削除してもよろしいですか?", + "batch_select_toggle": "複数選択を切り替え", + "batch_selected": "{count} 個選択中", + "batch_select_all": "絞り込み結果をすべて選択", + "batch_clear": "選択をクリア", + "batch_delete": "一括削除", + "batch_delete_confirm": "選択した {count} 個のアプリを削除しますか?", + "batch_delete_result": "{deleted} 個のアプリを削除しました(残り {remaining})", + "batch_delete_failed": "一括削除に失敗しました", "detached_cmds": "切り離されたコマンド", "detached_cmds_add": "別のコマンドを追加", "detached_cmds_desc": "バックグラウンドで実行するコマンドのリスト。", diff --git a/src_assets/common/assets/web/public/assets/locale/ko.json b/src_assets/common/assets/web/public/assets/locale/ko.json index 20813bc8313..cfd70fde4f1 100644 --- a/src_assets/common/assets/web/public/assets/locale/ko.json +++ b/src_assets/common/assets/web/public/assets/locale/ko.json @@ -54,6 +54,14 @@ "covers_found": "커버 발견", "delete": "삭제", "delete_confirm": "\"{name}\"을(를) 삭제하시겠습니까?", + "batch_select_toggle": "다중 선택 전환", + "batch_selected": "{count}개 선택됨", + "batch_select_all": "필터 결과 전체 선택", + "batch_clear": "선택 해제", + "batch_delete": "일괄 삭제", + "batch_delete_confirm": "선택한 {count}개 앱을 삭제하시겠습니까?", + "batch_delete_result": "{deleted}개 앱을 삭제했습니다(남은 항목 {remaining})", + "batch_delete_failed": "일괄 삭제 실패", "detached_cmds": "분리된 명령", "detached_cmds_add": "분리된 명령 추가", "detached_cmds_desc": "백그라운드에서 실행할 명령어 입니다.", diff --git a/src_assets/common/assets/web/public/assets/locale/pl.json b/src_assets/common/assets/web/public/assets/locale/pl.json index 7139cb78011..3423af68fed 100644 --- a/src_assets/common/assets/web/public/assets/locale/pl.json +++ b/src_assets/common/assets/web/public/assets/locale/pl.json @@ -54,6 +54,14 @@ "covers_found": "Znalezione okładki", "delete": "Usuń", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Przełącz wybór wielokrotny", + "batch_selected": "Zaznaczono: {count}", + "batch_select_all": "Zaznacz wszystkie filtrowane", + "batch_clear": "Wyczyść zaznaczenie", + "batch_delete": "Usuwanie zbiorcze", + "batch_delete_confirm": "Usunąć {count} zaznaczonych aplikacji?", + "batch_delete_result": "Usunięto {deleted} aplikacji, pozostało {remaining}", + "batch_delete_failed": "Usuwanie zbiorcze nie powiodło się", "detached_cmds": "Polecenia odłączone", "detached_cmds_add": "Dodaj odłączone polecenie", "detached_cmds_desc": "Lista poleceń uruchamianych w tle.", diff --git a/src_assets/common/assets/web/public/assets/locale/pt.json b/src_assets/common/assets/web/public/assets/locale/pt.json index 879a962a725..dcf2e9725e6 100644 --- a/src_assets/common/assets/web/public/assets/locale/pt.json +++ b/src_assets/common/assets/web/public/assets/locale/pt.json @@ -54,6 +54,14 @@ "covers_found": "Capas encontradas", "delete": "excluir", "delete_confirm": "Tem certeza de que deseja excluir \"{name}\"?", + "batch_select_toggle": "Alternar seleção múltipla", + "batch_selected": "{count} selecionados", + "batch_select_all": "Selecionar todos filtrados", + "batch_clear": "Limpar seleção", + "batch_delete": "Excluir em lote", + "batch_delete_confirm": "Excluir os {count} aplicativos selecionados?", + "batch_delete_result": "{deleted} aplicativos excluídos, {remaining} restantes", + "batch_delete_failed": "Falha na exclusão em lote", "detached_cmds": "Comandos desanexados", "detached_cmds_add": "Adicionar Comando Desanexado", "detached_cmds_desc": "Uma lista de comandos a serem executados em segundo plano.", diff --git a/src_assets/common/assets/web/public/assets/locale/pt_BR.json b/src_assets/common/assets/web/public/assets/locale/pt_BR.json index fa9f9550aae..6c0a0209885 100644 --- a/src_assets/common/assets/web/public/assets/locale/pt_BR.json +++ b/src_assets/common/assets/web/public/assets/locale/pt_BR.json @@ -54,6 +54,14 @@ "covers_found": "Capas encontradas", "delete": "Excluir", "delete_confirm": "Tem certeza de que deseja excluir \"{name}\"?", + "batch_select_toggle": "Alternar seleção múltipla", + "batch_selected": "{count} selecionados", + "batch_select_all": "Selecionar todos filtrados", + "batch_clear": "Limpar seleção", + "batch_delete": "Excluir em lote", + "batch_delete_confirm": "Excluir os {count} aplicativos selecionados?", + "batch_delete_result": "{deleted} aplicativos excluídos, {remaining} restantes", + "batch_delete_failed": "Falha na exclusão em lote", "detached_cmds": "Comandos destacados", "detached_cmds_add": "Adicionar comando destacado", "detached_cmds_desc": "Uma lista de comandos a serem executados em segundo plano.", diff --git a/src_assets/common/assets/web/public/assets/locale/ru.json b/src_assets/common/assets/web/public/assets/locale/ru.json index a407b88255b..c134fbbc500 100644 --- a/src_assets/common/assets/web/public/assets/locale/ru.json +++ b/src_assets/common/assets/web/public/assets/locale/ru.json @@ -54,6 +54,14 @@ "covers_found": "Найденные обложки", "delete": "Удалить", "delete_confirm": "Вы уверены, что хотите удалить \"{name}\"?", + "batch_select_toggle": "Переключить множественный выбор", + "batch_selected": "Выбрано: {count}", + "batch_select_all": "Выбрать все отфильтрованные", + "batch_clear": "Очистить выбор", + "batch_delete": "Пакетное удаление", + "batch_delete_confirm": "Удалить {count} выбранных приложений?", + "batch_delete_result": "Удалено {deleted} приложений, осталось {remaining}", + "batch_delete_failed": "Не удалось выполнить пакетное удаление", "detached_cmds": "Независимые команды", "detached_cmds_add": "Добавить независимую команду", "detached_cmds_desc": "Список команд, работающих в фоновом режиме.", diff --git a/src_assets/common/assets/web/public/assets/locale/sv.json b/src_assets/common/assets/web/public/assets/locale/sv.json index b499914a4ba..11bfc96e26b 100644 --- a/src_assets/common/assets/web/public/assets/locale/sv.json +++ b/src_assets/common/assets/web/public/assets/locale/sv.json @@ -54,6 +54,14 @@ "covers_found": "Hittade omslag", "delete": "Radera", "delete_confirm": "Är du säker på att du vill radera \"{name}\"?", + "batch_select_toggle": "Växla flerval", + "batch_selected": "{count} valda", + "batch_select_all": "Markera alla filtrerade", + "batch_clear": "Rensa val", + "batch_delete": "Massradering", + "batch_delete_confirm": "Radera {count} valda appar?", + "batch_delete_result": "{deleted} appar raderade, {remaining} kvar", + "batch_delete_failed": "Massradering misslyckades", "detached_cmds": "Fristående kommandon", "detached_cmds_add": "Lägg till fristående kommando", "detached_cmds_desc": "En lista över kommandon som ska köras i bakgrunden.", diff --git a/src_assets/common/assets/web/public/assets/locale/tr.json b/src_assets/common/assets/web/public/assets/locale/tr.json index c8a7f31511e..c437363f6d7 100644 --- a/src_assets/common/assets/web/public/assets/locale/tr.json +++ b/src_assets/common/assets/web/public/assets/locale/tr.json @@ -54,6 +54,14 @@ "covers_found": "Kapaklar Bulundu", "delete": "Sil", "delete_confirm": "\"{name}\" uygulamasını silmek istediğinizden emin misiniz?", + "batch_select_toggle": "Çoklu seçimi değiştir", + "batch_selected": "{count} seçili", + "batch_select_all": "Filtrelenmiş sonuçların tümünü seç", + "batch_clear": "Seçimi temizle", + "batch_delete": "Toplu sil", + "batch_delete_confirm": "Seçili {count} uygulama silinsin mi?", + "batch_delete_result": "{deleted} uygulama silindi, {remaining} kaldı", + "batch_delete_failed": "Toplu silme başarısız", "detached_cmds": "Müstakil Komutlar", "detached_cmds_add": "Müstakil Komut Ekleme", "detached_cmds_desc": "Arka planda çalıştırılacak komutların bir listesi.", diff --git a/src_assets/common/assets/web/public/assets/locale/uk.json b/src_assets/common/assets/web/public/assets/locale/uk.json index 9e426aa27bf..9904f1d414a 100644 --- a/src_assets/common/assets/web/public/assets/locale/uk.json +++ b/src_assets/common/assets/web/public/assets/locale/uk.json @@ -54,6 +54,14 @@ "covers_found": "Обкладинки знайдено", "delete": "Видалити", "delete_confirm": "Are you sure you want to delete \"{name}\"?", + "batch_select_toggle": "Перемкнути множинний вибір", + "batch_selected": "Вибрано: {count}", + "batch_select_all": "Вибрати всі відфільтровані", + "batch_clear": "Очистити вибір", + "batch_delete": "Пакетне видалення", + "batch_delete_confirm": "Видалити {count} вибраних додатків?", + "batch_delete_result": "Видалено {deleted} додатків, залишилося {remaining}", + "batch_delete_failed": "Пакетне видалення не вдалося", "detached_cmds": "Відокремлені команди", "detached_cmds_add": "Додати окрему команду", "detached_cmds_desc": "Список команд для запуску у фоновому режимі.", diff --git a/src_assets/common/assets/web/public/assets/locale/zh.json b/src_assets/common/assets/web/public/assets/locale/zh.json index f781fcaea56..a24a8587d52 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh.json +++ b/src_assets/common/assets/web/public/assets/locale/zh.json @@ -54,6 +54,14 @@ "covers_found": "找到的封面", "delete": "删除", "delete_confirm": "确定要删除应用 \"{name}\" 吗?", + "batch_select_toggle": "切换多选", + "batch_selected": "已选择 {count} 个应用", + "batch_select_all": "全选过滤结果", + "batch_clear": "清空选择", + "batch_delete": "批量删除", + "batch_delete_confirm": "确定要批量删除选中的 {count} 个应用吗?", + "batch_delete_result": "已删除 {deleted} 个应用,剩余 {remaining}", + "batch_delete_failed": "批量删除失败", "detached_cmds": "独立命令", "detached_cmds_add": "添加独立命令", "detached_cmds_desc": "在后台运行的命令列表", @@ -198,6 +206,21 @@ "amd_slices_per_frame": "AMF 每帧分片数", "amd_slices_per_frame_auto": "自动(由客户端决定)", "amd_slices_per_frame_desc": "将每帧分成多个分片/瓦片进行并行编码和解码,可降低延迟。对于 H.264/HEVC 设置每帧分片数;对于 AV1 设置每帧瓦片数。设为 0 由客户端决定。", + "amd_advanced_group": "AMF 高级调优", + "amd_advanced_group_desc": "可选的驱动级 AMF 属性。全部留空时使用 AMD 驱动默认值(与 FFmpeg amfenc 一致,对大多数用户最稳)。如需追求更低延迟可显式开启;如遇到硬件/驱动 bug(例如 RX 9070 / RDNA4 + Adrenalin 26.5.x 在 H.264/HEVC 下卡死,见 issue #666)可显式关闭对应项。", + "amd_advanced_reset": "全部重置为驱动默认", + "amd_driver_default": "驱动默认 (推荐)", + "amd_high_motion_qb": "AMF 高运动质量增强", + "amd_high_motion_qb_desc": "针对快速运动画面的编码器级质量增强。留空使用驱动默认。'启用' 为 Sunshine 旧行为,可能提升画质;遇到驱动卡死可选 '禁用'。", + "amd_lowlatency_mode": "AMF 低延迟模式", + "amd_lowlatency_mode_desc": "AMD VCN 低延迟代码路径。留空使用驱动默认。'启用' 为 Sunshine 旧行为,能将延迟降至最低;遇到驱动卡死(RX 9070 / RDNA4 H.264/HEVC)可选 '禁用'。", + "amd_input_queue_size": "AMF 输入队列大小", + "amd_input_queue_size_desc": "编码器内部可缓冲的帧数(1-16)。留空使用驱动默认(FFmpeg 为 16)。Sunshine 旧值为 1 以最小化延迟。值越小延迟越低,值越大越稳定。", + "amd_av1_latency_mode": "AMF AV1 编码延迟模式", + "amd_av1_latency_mode_power_saving": "省电实时", + "amd_av1_latency_mode_real_time": "实时", + "amd_av1_latency_mode_lowest": "最低延迟", + "amd_av1_latency_mode_desc": "仅 AV1 的延迟模式。留空使用驱动默认。Sunshine 旧值为 '最低延迟'。如不确定请保持驱动默认。", "amd_usage": "AMF 工作模式", "amd_usage_desc": "设置基本编码配置文件。 以下列出的所有选项将覆盖使用情况简介的子集,但是应用到了其他不可配置的隐藏设置。", "amd_usage_lowlatency": "lowlatency - 低延迟(最快)", diff --git a/src_assets/common/assets/web/public/assets/locale/zh_TW.json b/src_assets/common/assets/web/public/assets/locale/zh_TW.json index 54ba660dbc8..9f3474858d3 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh_TW.json +++ b/src_assets/common/assets/web/public/assets/locale/zh_TW.json @@ -54,6 +54,14 @@ "covers_found": "找到封面圖片", "delete": "刪除", "delete_confirm": "確定要刪除應用 \"{name}\" 嗎?", + "batch_select_toggle": "切換多選", + "batch_selected": "已選擇 {count} 個應用", + "batch_select_all": "全選過濾結果", + "batch_clear": "清空選擇", + "batch_delete": "批次刪除", + "batch_delete_confirm": "確定要批次刪除選中的 {count} 個應用嗎?", + "batch_delete_result": "已刪除 {deleted} 個應用,剩餘 {remaining}", + "batch_delete_failed": "批次刪除失敗", "detached_cmds": "獨立指令", "detached_cmds_add": "新增獨立指令", "detached_cmds_desc": "在背景執行的指令清單。", diff --git a/src_assets/common/assets/web/services/appService.js b/src_assets/common/assets/web/services/appService.js index 3cc4aaf1d3d..0c8866bfce0 100644 --- a/src_assets/common/assets/web/services/appService.js +++ b/src_assets/common/assets/web/services/appService.js @@ -75,6 +75,33 @@ export class AppService { } } + /** + * 批量删除应用(原子操作,按单次快照解释 indices) + * @param {number[]} indices 应用索引数组 + * @returns {Promise<{deleted:number, remaining:number}>} + */ + static async batchDeleteApps(indices) { + try { + const response = await fetch(API_ENDPOINTS.APPS_BATCH_DELETE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ indices }) + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok || data.status === false || data.status === 'false') { + throw new Error(data.error || `批量删除失败: ${response.status}`); + } + return { + deleted: Number(data.deleted) || 0, + remaining: Number(data.remaining) || 0 + }; + } catch (error) { + console.error('批量删除应用失败:', error); + throw new Error(formatError(error)); + } + } + /** * 获取平台信息 * @returns {Promise} 平台信息 diff --git a/src_assets/common/assets/web/styles/apps.less b/src_assets/common/assets/web/styles/apps.less index c482d49fb63..44a77b9457c 100644 --- a/src_assets/common/assets/web/styles/apps.less +++ b/src_assets/common/assets/web/styles/apps.less @@ -508,6 +508,84 @@ } // ============================================================================ +// 批量选择 - 工具栏 + 卡片复选框覆盖层 +// ============================================================================ +.batch-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 10px 16px; + margin-bottom: 16px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(6px); + + .batch-action-info { + color: var(--text-secondary, #cbd5e1); + font-size: var(--font-size-sm, 14px); + } + + .batch-action-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + } +} + +.app-card-wrapper, +.app-list-wrapper { + position: relative; + + &.selection-mode { + cursor: pointer; + } + + &.is-selected { + // 用细描边而不是 box-shadow 抢占视觉,避免和 hover 状态打架 + outline: 2px solid var(--bs-primary, #6366f1); + outline-offset: 2px; + border-radius: 14px; + } +} + +.app-select-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 20; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: rgba(15, 23, 42, 0.72); + color: #fff; + font-size: 16px; + cursor: pointer; + user-select: none; + pointer-events: auto; + transition: background 0.15s ease; + + &:hover { + background: rgba(99, 102, 241, 0.9); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary, #6366f1); + outline-offset: 2px; + } + + &--list { + top: 50%; + left: 8px; + transform: translateY(-50%); + } +} + // APP CARD // ============================================================================ diff --git a/src_assets/common/assets/web/utils/constants.js b/src_assets/common/assets/web/utils/constants.js index 14abe070e7c..4d1a6b0df9a 100644 --- a/src_assets/common/assets/web/utils/constants.js +++ b/src_assets/common/assets/web/utils/constants.js @@ -82,5 +82,6 @@ export const ENV_VARS_CONFIG = { export const API_ENDPOINTS = { APPS: '/api/apps', CONFIG: '/api/config', - APP_DELETE: (index) => `/api/apps/${index}` + APP_DELETE: (index) => `/api/apps/${index}`, + APPS_BATCH_DELETE: '/api/apps/batch-delete' }; \ No newline at end of file diff --git a/src_assets/common/assets/web/views/Apps.vue b/src_assets/common/assets/web/views/Apps.vue index 51e2f19b25c..9c6659bd84b 100644 --- a/src_assets/common/assets/web/views/Apps.vue +++ b/src_assets/common/assets/web/views/Apps.vue @@ -47,6 +47,17 @@ + + + + + + +
@@ -96,33 +136,66 @@ @end="onDragEnd" >
- + class="app-card-wrapper" + :class="{ 'selection-mode': selectionMode, 'is-selected': isAppSelected(getOriginalIndex(app)) }" + > + + +
@@ -141,32 +214,65 @@ @end="onDragEnd" >
- + class="app-list-wrapper" + :class="{ 'selection-mode': selectionMode, 'is-selected': isAppSelected(getOriginalIndex(app)) }" + > + + +
@@ -331,6 +437,31 @@ sh -c "displayplacer "id:<screenId> res:${SUNSHINE_CLIENT_WIDTH}x${SUNSHIN + + +
+
+
+
+ {{ $t('apps.batch_delete') }} +
+ +
+
+

{{ $t('apps.batch_delete_confirm', { count: selectedIndices.size }) }}

+
+ +
+
+
@@ -378,6 +509,18 @@ const { cancelDeleteApp, confirmDeleteApp, deleteConfirmIndex, + selectionMode, + selectedIndices, + batchDeleteConfirm, + isBatchDeleting, + toggleSelectionMode, + toggleAppSelection, + isAppSelected, + selectAllFiltered, + clearSelection, + askBatchDelete, + cancelBatchDelete, + confirmBatchDelete, save, hasUnsavedChanges, onDragStart,