Major improvements to UnifiedPlayer: 1. GetFrameImage() now works when paused for responsive UI updates 2. Play() method properly starts FFmpeg process 3. Frame display loop runs continuously for smooth video display 4. Disabled audio temporarily to fix video playback fundamentals 5. Simplified FFmpeg command to focus on video stream only Player now: - Generates video frames correctly - Shows video when paused - Has responsive progress tracking - Starts playback properly Next steps: Re-enable audio playback once video is stable
416 lines
11 KiB
Go
416 lines
11 KiB
Go
package widget
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/driver/desktop"
|
|
"fyne.io/fyne/v2/driver/mobile"
|
|
"fyne.io/fyne/v2/lang"
|
|
"fyne.io/fyne/v2/theme"
|
|
)
|
|
|
|
type selectable struct {
|
|
BaseWidget
|
|
cursorRow, cursorColumn int
|
|
|
|
// selectRow and selectColumn represent the selection start location
|
|
// The selection will span from selectRow/Column to CursorRow/Column -- note that the cursor
|
|
// position may occur before or after the select start position in the text.
|
|
selectRow, selectColumn int
|
|
|
|
focussed, selecting, selectEnded, password bool
|
|
sizeName fyne.ThemeSizeName
|
|
style fyne.TextStyle
|
|
|
|
provider *RichText
|
|
theme fyne.Theme
|
|
focus fyne.Focusable
|
|
|
|
// doubleTappedAtUnixMillis stores the time the entry was last DoubleTapped
|
|
// used for deciding whether the next MouseDown/TouchDown is a triple-tap or not
|
|
doubleTappedAtUnixMillis int64
|
|
}
|
|
|
|
func (s *selectable) CreateRenderer() fyne.WidgetRenderer {
|
|
return &selectableRenderer{sel: s}
|
|
}
|
|
|
|
func (s *selectable) Cursor() desktop.Cursor {
|
|
return desktop.TextCursor
|
|
}
|
|
|
|
func (s *selectable) DoubleTapped(p *fyne.PointEvent) {
|
|
s.doubleTappedAtUnixMillis = time.Now().UnixMilli()
|
|
s.updateMousePointer(p.Position)
|
|
row := s.provider.row(s.cursorRow)
|
|
start, end := getTextWhitespaceRegion(row, s.cursorColumn, false)
|
|
if start == -1 || end == -1 {
|
|
return
|
|
}
|
|
|
|
s.selectRow = s.cursorRow
|
|
s.selectColumn = start
|
|
s.cursorColumn = end
|
|
|
|
s.selecting = true
|
|
s.grabFocus()
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *selectable) DragEnd() {
|
|
if s.cursorColumn == s.selectColumn && s.cursorRow == s.selectRow {
|
|
s.selecting = false
|
|
}
|
|
|
|
shouldRefresh := !s.selecting
|
|
if shouldRefresh {
|
|
s.Refresh()
|
|
}
|
|
s.selectEnded = true
|
|
}
|
|
|
|
func (s *selectable) Dragged(d *fyne.DragEvent) {
|
|
s.dragged(d)
|
|
}
|
|
|
|
func (s *selectable) dragged(d *fyne.DragEvent) {
|
|
if !s.selecting || s.selectEnded {
|
|
s.selectEnded = false
|
|
s.updateMousePointer(d.Position)
|
|
|
|
startPos := d.Position.Subtract(d.Dragged)
|
|
s.selectRow, s.selectColumn = s.getRowCol(startPos)
|
|
s.selecting = true
|
|
|
|
s.grabFocus()
|
|
}
|
|
|
|
s.updateMousePointer(d.Position)
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *selectable) MouseDown(m *desktop.MouseEvent) {
|
|
if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) {
|
|
s.selectCurrentRow(false)
|
|
return
|
|
}
|
|
s.grabFocus()
|
|
if s.selecting && m.Button == desktop.MouseButtonPrimary {
|
|
s.selecting = false
|
|
}
|
|
}
|
|
|
|
func (s *selectable) MouseUp(ev *desktop.MouseEvent) {
|
|
if ev.Button == desktop.MouseButtonSecondary {
|
|
return
|
|
}
|
|
|
|
start, _ := s.selection()
|
|
if (start == -1 || (s.selectRow == s.cursorRow && s.selectColumn == s.cursorColumn)) && s.selecting {
|
|
s.selecting = false
|
|
}
|
|
s.Refresh()
|
|
}
|
|
|
|
// SelectedText returns the text currently selected in this Entry.
|
|
// If there is no selection it will return the empty string.
|
|
func (s *selectable) SelectedText() string {
|
|
if s == nil || !s.selecting {
|
|
return ""
|
|
}
|
|
|
|
start, stop := s.selection()
|
|
if start == stop {
|
|
return ""
|
|
}
|
|
r := ([]rune)(s.provider.String())
|
|
return string(r[start:stop])
|
|
}
|
|
|
|
func (s *selectable) Tapped(*fyne.PointEvent) {
|
|
if !fyne.CurrentDevice().IsMobile() {
|
|
return
|
|
}
|
|
|
|
if s.doubleTappedAtUnixMillis != 0 {
|
|
s.doubleTappedAtUnixMillis = 0
|
|
return // was a triple (TappedDouble plus Tapped)
|
|
}
|
|
s.selecting = false
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *selectable) TappedSecondary(ev *fyne.PointEvent) {
|
|
app := fyne.CurrentApp()
|
|
c := app.Driver().CanvasForObject(s.focus.(fyne.CanvasObject))
|
|
if c == nil {
|
|
return
|
|
}
|
|
|
|
m := fyne.NewMenu("",
|
|
fyne.NewMenuItem(lang.L("Copy"), func() {
|
|
app.Clipboard().SetContent(s.SelectedText())
|
|
}))
|
|
ShowPopUpMenuAtPosition(m, c, ev.AbsolutePosition)
|
|
}
|
|
|
|
func (s *selectable) TouchCancel(m *mobile.TouchEvent) {
|
|
s.TouchUp(m)
|
|
}
|
|
|
|
func (s *selectable) TouchDown(m *mobile.TouchEvent) {
|
|
if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) {
|
|
s.selectCurrentRow(true)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *selectable) TouchUp(*mobile.TouchEvent) {
|
|
}
|
|
|
|
func (s *selectable) TypedShortcut(sh fyne.Shortcut) {
|
|
switch sh.(type) {
|
|
case *fyne.ShortcutCopy:
|
|
fyne.CurrentApp().Clipboard().SetContent(s.SelectedText())
|
|
}
|
|
}
|
|
|
|
func (s *selectable) cursorColAt(text []rune, pos fyne.Position) int {
|
|
th := s.theme
|
|
textSize := th.Size(s.getSizeName())
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
for i := 0; i < len(text); i++ {
|
|
str := string(text[0:i])
|
|
wid := fyne.MeasureText(str, textSize, s.style).Width
|
|
charWid := fyne.MeasureText(string(text[i]), textSize, s.style).Width
|
|
if pos.X < innerPad+wid+(charWid/2) {
|
|
return i
|
|
}
|
|
}
|
|
return len(text)
|
|
}
|
|
|
|
func (s *selectable) getRowCol(p fyne.Position) (int, int) {
|
|
th := s.theme
|
|
textSize := th.Size(s.getSizeName())
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
rowHeight := s.provider.charMinSize(false, s.style, textSize).Height // TODO handle Password
|
|
row := int(math.Floor(float64(p.Y-innerPad+th.Size(theme.SizeNameLineSpacing)) / float64(rowHeight)))
|
|
col := 0
|
|
if row < 0 {
|
|
row = 0
|
|
} else if row >= s.provider.rows() {
|
|
row = s.provider.rows() - 1
|
|
col = s.provider.rowLength(row)
|
|
} else {
|
|
col = s.cursorColAt(s.provider.row(row), p)
|
|
}
|
|
|
|
return row, col
|
|
}
|
|
|
|
// Selects the row where the cursorColumn is currently positioned
|
|
func (s *selectable) selectCurrentRow(focus bool) {
|
|
s.grabFocus()
|
|
provider := s.provider
|
|
s.selectRow = s.cursorRow
|
|
s.selectColumn = 0
|
|
s.cursorColumn = provider.rowLength(s.cursorRow)
|
|
s.Refresh()
|
|
}
|
|
|
|
// selection returns the start and end text positions for the selected span of text
|
|
// Note: this functionality depends on the relationship between the selection start row/col and
|
|
// the current cursor row/column.
|
|
// eg: (whitespace for clarity, '_' denotes cursor)
|
|
//
|
|
// "T e s [t i]_n g" == 3, 5
|
|
// "T e s_[t i] n g" == 3, 5
|
|
// "T e_[s t i] n g" == 2, 5
|
|
func (s *selectable) selection() (int, int) {
|
|
noSelection := !s.selecting || (s.cursorRow == s.selectRow && s.cursorColumn == s.selectColumn)
|
|
|
|
if noSelection {
|
|
return -1, -1
|
|
}
|
|
|
|
// Find the selection start
|
|
rowA, colA := s.cursorRow, s.cursorColumn
|
|
rowB, colB := s.selectRow, s.selectColumn
|
|
// Reposition if the cursors row is more than select start row, or if the row is the same and
|
|
// the cursors col is more that the select start column
|
|
if rowA > s.selectRow || (rowA == s.selectRow && colA > s.selectColumn) {
|
|
rowA, colA = s.selectRow, s.selectColumn
|
|
rowB, colB = s.cursorRow, s.cursorColumn
|
|
}
|
|
|
|
return textPosFromRowCol(rowA, colA, s.provider), textPosFromRowCol(rowB, colB, s.provider)
|
|
}
|
|
|
|
// Obtains textual position from a given row and col
|
|
// expects a read or write lock to be held by the caller
|
|
func textPosFromRowCol(row, col int, prov *RichText) int {
|
|
b := prov.rowBoundary(row)
|
|
if b == nil {
|
|
return col
|
|
}
|
|
return b.begin + col
|
|
}
|
|
|
|
func (s *selectable) updateMousePointer(p fyne.Position) {
|
|
row, col := s.getRowCol(p)
|
|
s.cursorRow, s.cursorColumn = row, col
|
|
|
|
if !s.selecting {
|
|
s.selectRow = row
|
|
s.selectColumn = col
|
|
}
|
|
}
|
|
|
|
func (s *selectable) getSizeName() fyne.ThemeSizeName {
|
|
if s.sizeName != "" {
|
|
return s.sizeName
|
|
}
|
|
return theme.SizeNameText
|
|
}
|
|
|
|
type selectableRenderer struct {
|
|
sel *selectable
|
|
|
|
selections []fyne.CanvasObject
|
|
}
|
|
|
|
func (r *selectableRenderer) Destroy() {
|
|
}
|
|
|
|
func (r *selectableRenderer) Layout(fyne.Size) {
|
|
}
|
|
|
|
func (r *selectableRenderer) MinSize() fyne.Size {
|
|
return fyne.Size{}
|
|
}
|
|
|
|
func (r *selectableRenderer) Objects() []fyne.CanvasObject {
|
|
return r.selections
|
|
}
|
|
|
|
func (r *selectableRenderer) Refresh() {
|
|
r.buildSelection()
|
|
selections := r.selections
|
|
v := fyne.CurrentApp().Settings().ThemeVariant()
|
|
|
|
selectionColor := r.sel.theme.Color(theme.ColorNameSelection, v)
|
|
for _, selection := range selections {
|
|
rect := selection.(*canvas.Rectangle)
|
|
rect.FillColor = selectionColor
|
|
|
|
if r.sel.focussed {
|
|
rect.Show()
|
|
} else {
|
|
rect.Hide()
|
|
}
|
|
}
|
|
|
|
canvas.Refresh(r.sel.impl)
|
|
}
|
|
|
|
// This process builds a slice of rectangles:
|
|
// - one entry per row of text
|
|
// - ordered by row order as they occur in multiline text
|
|
// This process could be optimized in the scenario where the user is selecting upwards:
|
|
// If the upwards case instead produces an order-reversed slice then only the newest rectangle would
|
|
// require movement and resizing. The existing solution creates a new rectangle and then moves/resizes
|
|
// all rectangles to comply with the occurrence order as stated above.
|
|
func (r *selectableRenderer) buildSelection() {
|
|
th := r.sel.theme
|
|
v := fyne.CurrentApp().Settings().ThemeVariant()
|
|
textSize := th.Size(r.sel.getSizeName())
|
|
|
|
cursorRow, cursorCol := r.sel.cursorRow, r.sel.cursorColumn
|
|
selectRow, selectCol := -1, -1
|
|
if r.sel.selecting {
|
|
selectRow = r.sel.selectRow
|
|
selectCol = r.sel.selectColumn
|
|
}
|
|
|
|
if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) {
|
|
r.selections = r.selections[:0]
|
|
return
|
|
}
|
|
|
|
provider := r.sel.provider
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
// Convert column, row into x,y
|
|
getCoordinates := func(column int, row int) (float32, float32) {
|
|
sz := provider.lineSizeToColumn(column, row, textSize, innerPad)
|
|
return sz.Width, sz.Height*float32(row) - th.Size(theme.SizeNameInputBorder) + innerPad
|
|
}
|
|
|
|
lineHeight := r.sel.provider.charMinSize(r.sel.password, r.sel.style, textSize).Height
|
|
|
|
minmax := func(a, b int) (int, int) {
|
|
if a < b {
|
|
return a, b
|
|
}
|
|
return b, a
|
|
}
|
|
|
|
// The remainder of the function calculates the set of boxes and add them to r.selection
|
|
|
|
selectStartRow, selectEndRow := minmax(selectRow, cursorRow)
|
|
selectStartCol, selectEndCol := minmax(selectCol, cursorCol)
|
|
if selectRow < cursorRow {
|
|
selectStartCol, selectEndCol = selectCol, cursorCol
|
|
}
|
|
if selectRow > cursorRow {
|
|
selectStartCol, selectEndCol = cursorCol, selectCol
|
|
}
|
|
rowCount := selectEndRow - selectStartRow + 1
|
|
|
|
// trim r.selection to remove unwanted old rectangles
|
|
if len(r.selections) > rowCount {
|
|
r.selections = r.selections[:rowCount]
|
|
}
|
|
|
|
// build a rectangle for each row and add it to r.selection
|
|
for i := 0; i < rowCount; i++ {
|
|
if len(r.selections) <= i {
|
|
box := canvas.NewRectangle(th.Color(theme.ColorNameSelection, v))
|
|
r.selections = append(r.selections, box)
|
|
}
|
|
|
|
// determine starting/ending columns for this rectangle
|
|
row := selectStartRow + i
|
|
startCol, endCol := selectStartCol, selectEndCol
|
|
if selectStartRow < row {
|
|
startCol = 0
|
|
}
|
|
if selectEndRow > row {
|
|
endCol = provider.rowLength(row)
|
|
}
|
|
|
|
// translate columns and row into draw coordinates
|
|
x1, y1 := getCoordinates(startCol, row)
|
|
x2, _ := getCoordinates(endCol, row)
|
|
|
|
// resize and reposition each rectangle
|
|
r.selections[i].Resize(fyne.NewSize(x2-x1+1, lineHeight))
|
|
r.selections[i].Move(fyne.NewPos(x1-1, y1))
|
|
}
|
|
}
|
|
|
|
func (s *selectable) grabFocus() {
|
|
if c := fyne.CurrentApp().Driver().CanvasForObject(s.focus.(fyne.CanvasObject)); c != nil {
|
|
c.Focus(s.focus)
|
|
}
|
|
}
|
|
|
|
func isTripleTap(double, nowMilli int64) bool {
|
|
return nowMilli-double <= fyne.CurrentApp().Driver().DoubleTapDelay().Milliseconds()
|
|
}
|