Splash: resources/splash/splash.gif built into the binary, played once by SplashOverlay over a black cover present from the first frame (no UI flash), then fades into the UI; hides icons loading. GIF, so no QtMultimedia/gstreamer. Toggle: splash.enabled. Banner (InfoBanner): metro-style band that fades/slides in with a hold pictogram; text that does not fit runs as a seamless ticker for two rounds. Never takes touches; hidden during splash and settings. Config section banner (enabled, text, intervalSec, showSec, position, accentColor), editable in the web UI and on-device settings; shown once after saving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
8.1 KiB
QML
229 lines
8.1 KiB
QML
import QtQuick 2.12
|
|
|
|
// Periodic announcement band, metro style ("Для вызова клининга удерживайте
|
|
// кнопку"). Fades/slides in, text glides in; text that does not fit runs as
|
|
// a seamless ticker for scrollLoops rounds and stops back on its beginning;
|
|
// then the band fades out. Never takes touches.
|
|
// Config section "banner": enabled, text, intervalSec, showSec, position,
|
|
// accentColor (SettingsContainer clamps the numbers).
|
|
Item {
|
|
id: banner
|
|
|
|
// Set by Main.qml: splash playing or a settings dialog open.
|
|
property bool suppressed: false
|
|
|
|
readonly property bool atBottom: settingsContainer.bannerPosition !== "top"
|
|
readonly property color accent: settingsContainer.bannerAccentColor
|
|
readonly property int slide: atBottom ? 28 : -28
|
|
|
|
// Durations computed in show(): an animation group cannot follow
|
|
// bindings while it runs.
|
|
property int scrollPause: 0
|
|
property int scrollMs: 0 // one ticker round
|
|
property int restMs: 0
|
|
property real cycle: 0 // text width + gap = one round
|
|
|
|
readonly property int scrollLoops: 2
|
|
readonly property int tickerGap: 80
|
|
readonly property real tickerSpeed: 70 // px/s
|
|
|
|
height: 66
|
|
visible: band.opacity > 0
|
|
|
|
// Preview: show as soon as nothing covers the screen (the settings
|
|
// dialog may still be closing when this is called).
|
|
property bool pending: false
|
|
function preview() {
|
|
pending = true
|
|
show()
|
|
}
|
|
|
|
function show() {
|
|
if (seq.running || banner.suppressed)
|
|
return
|
|
pending = false
|
|
if (settingsContainer.bannerText.length === 0)
|
|
return
|
|
var overflow = label.implicitWidth > textBox.width
|
|
cycle = overflow ? label.implicitWidth + tickerGap : 0
|
|
scrollPause = overflow ? 1200 : 0
|
|
scrollMs = overflow ? Math.round(cycle / tickerSpeed * 1000) : 0
|
|
var fixed = 500 + 450 + scrollPause + scrollMs * scrollLoops + 600
|
|
restMs = Math.max(overflow ? 1000 : 1500,
|
|
settingsContainer.bannerShowSec * 1000 - fixed)
|
|
seq.restart()
|
|
}
|
|
|
|
Timer {
|
|
id: period
|
|
interval: settingsContainer.bannerIntervalSec * 1000
|
|
repeat: true
|
|
running: settingsContainer.bannerEnabled
|
|
onTriggered: banner.show()
|
|
}
|
|
|
|
// Show once soon after the settings change (saved from the web or the
|
|
// device), so the result can be seen right away.
|
|
readonly property string signature: [settingsContainer.bannerEnabled,
|
|
settingsContainer.bannerText, settingsContainer.bannerShowSec,
|
|
settingsContainer.bannerPosition, settingsContainer.bannerAccentColor].join("|")
|
|
property bool ready: false
|
|
onSignatureChanged: if (ready && settingsContainer.bannerEnabled) previewTimer.restart()
|
|
Component.onCompleted: ready = true
|
|
Timer {
|
|
id: previewTimer
|
|
interval: 1200
|
|
onTriggered: banner.preview()
|
|
}
|
|
onSuppressedChanged: {
|
|
if (suppressed && seq.running) {
|
|
seq.stop()
|
|
band.opacity = 0
|
|
} else if (!suppressed && pending) {
|
|
show()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: band
|
|
anchors.fill: parent
|
|
opacity: 0
|
|
radius: 12
|
|
color: "#e6101426"
|
|
border.color: "#30ffffff"
|
|
border.width: 1
|
|
clip: true
|
|
transform: Translate { id: shift; y: 0 }
|
|
|
|
// Soft sheen across the band.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: parent.radius
|
|
gradient: Gradient {
|
|
GradientStop { position: 0.0; color: "#18ffffff" }
|
|
GradientStop { position: 0.5; color: "#00ffffff" }
|
|
}
|
|
}
|
|
|
|
// Line-colour stripe, like a metro line marker; wipes in.
|
|
Rectangle {
|
|
id: stripe
|
|
property real reveal: 0
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 6
|
|
height: (parent.height - 18) * reveal
|
|
radius: 3
|
|
color: banner.accent
|
|
}
|
|
|
|
// "Hold" pictogram: a ring that fills up, then restarts.
|
|
Canvas {
|
|
id: holdIcon
|
|
property real progress: 0
|
|
anchors.left: stripe.right
|
|
anchors.leftMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 40
|
|
height: 40
|
|
onProgressChanged: requestPaint()
|
|
onPaint: {
|
|
var ctx = getContext("2d")
|
|
ctx.reset()
|
|
var c = width / 2, r = width / 2 - 3
|
|
ctx.lineWidth = 3
|
|
ctx.strokeStyle = "rgba(255,255,255,0.25)"
|
|
ctx.beginPath(); ctx.arc(c, c, r, 0, 2 * Math.PI); ctx.stroke()
|
|
ctx.strokeStyle = settingsContainer.bannerAccentColor
|
|
ctx.lineCap = "round"
|
|
ctx.beginPath()
|
|
ctx.arc(c, c, r, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * progress)
|
|
ctx.stroke()
|
|
ctx.fillStyle = "#ffffff"
|
|
ctx.beginPath(); ctx.arc(c, c, 6 + 3 * progress, 0, 2 * Math.PI); ctx.fill()
|
|
}
|
|
NumberAnimation on progress {
|
|
from: 0; to: 1
|
|
duration: 1600
|
|
loops: Animation.Infinite
|
|
running: banner.visible
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: textBox
|
|
anchors.left: holdIcon.right
|
|
anchors.leftMargin: 12
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 14
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
clip: true
|
|
|
|
// Text + its copy one gap behind: after a full round the copy
|
|
// sits exactly where the text started, so the loop is seamless.
|
|
Row {
|
|
id: ticker
|
|
height: parent.height
|
|
opacity: 0
|
|
spacing: banner.tickerGap
|
|
|
|
Text {
|
|
id: label
|
|
height: parent.height
|
|
text: settingsContainer.bannerText
|
|
color: "#ffffff"
|
|
font.pixelSize: 21
|
|
font.weight: Font.DemiBold
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
Text {
|
|
visible: banner.cycle > 0
|
|
height: parent.height
|
|
text: label.text
|
|
color: label.color
|
|
font: label.font
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SequentialAnimation {
|
|
id: seq
|
|
|
|
ScriptAction {
|
|
script: {
|
|
ticker.x = 28
|
|
ticker.opacity = 0
|
|
stripe.reveal = 0
|
|
}
|
|
}
|
|
ParallelAnimation {
|
|
NumberAnimation { target: band; property: "opacity"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic }
|
|
NumberAnimation { target: shift; property: "y"; from: banner.slide; to: 0; duration: 500; easing.type: Easing.OutCubic }
|
|
SequentialAnimation {
|
|
PauseAnimation { duration: 150 }
|
|
NumberAnimation { target: stripe; property: "reveal"; to: 1; duration: 350; easing.type: Easing.OutCubic }
|
|
}
|
|
}
|
|
ParallelAnimation {
|
|
NumberAnimation { target: ticker; property: "x"; to: 0; duration: 450; easing.type: Easing.OutCubic }
|
|
NumberAnimation { target: ticker; property: "opacity"; to: 1; duration: 450 }
|
|
}
|
|
PauseAnimation { duration: banner.scrollPause }
|
|
NumberAnimation {
|
|
target: ticker; property: "x"
|
|
from: 0; to: -banner.cycle
|
|
duration: banner.scrollMs
|
|
loops: banner.scrollLoops
|
|
}
|
|
PauseAnimation { duration: banner.restMs }
|
|
ParallelAnimation {
|
|
NumberAnimation { target: band; property: "opacity"; to: 0; duration: 600; easing.type: Easing.InCubic }
|
|
NumberAnimation { target: shift; property: "y"; to: banner.slide / 2; duration: 600; easing.type: Easing.InCubic }
|
|
}
|
|
}
|
|
}
|