fix: enable window dragging outside interactive controls
Use background-only native drag gestures so scroll content and overlays remain draggable without stealing control, text, or queue-row input. Compact mode uses the same explicit drag surfaces rather than broad native background movement.
This commit is contained in:
@@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer):
|
||||
super()._get(request)
|
||||
|
||||
|
||||
def check_window_drag(root, app, fixtures):
|
||||
result_path = root / "window-drag.json"
|
||||
diagnostics = root / "window-drag.log"
|
||||
result = subprocess.run(
|
||||
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path), str(fixtures)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
if result.returncode != 0 or not report.get("passed"):
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
raise RuntimeError(f"native window drag failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print("PASS native window dragging: " + str(report))
|
||||
|
||||
|
||||
def check_queue_drag(root, app, fixtures):
|
||||
result_path = root / "queue-drag.json"
|
||||
diagnostics = root / "queue-drag.log"
|
||||
@@ -671,6 +686,8 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
||||
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
|
||||
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
|
||||
check_window_drag(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
|
||||
check_queue_drag(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
|
||||
check_window_layout(root, app, fixtures)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import MediaPlayer
|
||||
import ObjectiveC
|
||||
|
||||
@_cdecl("terebi_app_main")
|
||||
@MainActor
|
||||
public func checkAppStartup() {
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") {
|
||||
WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
|
||||
return
|
||||
}
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") {
|
||||
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
|
||||
return
|
||||
@@ -20,6 +25,195 @@ public func checkAppStartup() {
|
||||
AppStartupCheck.run()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum WindowDragCheck {
|
||||
static var timer: Timer?
|
||||
static var resultURL: URL!
|
||||
static var phase = 0
|
||||
static var probe = 0
|
||||
static var drags = 0
|
||||
static var previousDrags = 0
|
||||
static var start = NSPoint.zero
|
||||
static var implementations: [(Method, IMP)] = []
|
||||
static var target: NSWindow?
|
||||
static var dragDepth = 0
|
||||
static let names = ["video", "title", "playback padding", "play button", "volume slider",
|
||||
"start whitespace", "start button", "start artwork", "URL text field",
|
||||
"queue whitespace", "queue footer", "queue row", "queue button",
|
||||
"error backdrop", "error button", "audio artwork",
|
||||
"compact video", "compact padding", "compact button", "hidden controls"]
|
||||
static let controls: Set<Int> = [3, 4, 6, 8, 11, 12, 14, 18]
|
||||
// Infrastructure timeout only; verdicts depend on input routing, not elapsed time.
|
||||
static let deadline = ContinuousClock.now.advanced(by: .seconds(25))
|
||||
|
||||
static func run(result: URL) {
|
||||
resultURL = result
|
||||
_ = NSApplication.shared
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
||||
MainActor.assumeIsolated { advance() }
|
||||
}
|
||||
RunLoop.main.add(timer!, forMode: .common)
|
||||
terebiAppMain()
|
||||
}
|
||||
|
||||
static func finish(_ error: String? = nil) {
|
||||
timer?.invalidate()
|
||||
for (method, original) in implementations {
|
||||
imp_removeBlock(method_setImplementation(method, original))
|
||||
}
|
||||
try! JSONSerialization.data(withJSONObject: [
|
||||
"passed": error == nil, "error": error ?? "", "probes": probe, "drags": drags,
|
||||
]).write(to: resultURL, options: .atomic)
|
||||
PlayerModel.shared.shutdown()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
static func nativeView(in view: NSView, matching predicate: (NSView) -> Bool) -> NSView? {
|
||||
if predicate(view) { return view }
|
||||
for child in view.subviews {
|
||||
if let found = nativeView(in: child, matching: predicate) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func point(in window: NSWindow) -> NSPoint? {
|
||||
let content = window.contentView!
|
||||
let w = content.bounds.width
|
||||
let h = content.bounds.height
|
||||
let view: NSView?
|
||||
switch probe {
|
||||
case 0, 7: return NSPoint(x: 100, y: 160)
|
||||
case 1: return NSPoint(x: 150, y: h - 16)
|
||||
case 2: return NSPoint(x: w / 2 - 210, y: 48)
|
||||
case 3: return NSPoint(x: w / 2, y: 64)
|
||||
case 4: view = nativeView(in: content) { ($0 as? NSSlider)?.accessibilityLabel() == "Volume" }
|
||||
case 5: return NSPoint(x: 600, y: 160)
|
||||
case 6: return NSPoint(x: 400, y: 272)
|
||||
case 8: view = nativeView(in: content) { ($0 as? NSTextField)?.isEditable == true }
|
||||
case 9: return NSPoint(x: w - 130, y: h / 2)
|
||||
case 10: return NSPoint(x: w - 160, y: 18)
|
||||
case 11: return NSPoint(x: w - 130, y: h - 80)
|
||||
case 12: return NSPoint(x: w - 183, y: 18)
|
||||
case 13: return NSPoint(x: 100, y: h / 2)
|
||||
case 14: return NSPoint(x: w / 2, y: h / 2 - 51.5)
|
||||
case 15, 19: return NSPoint(x: w / 2, y: h / 2)
|
||||
case 16: return NSPoint(x: 100, y: 100)
|
||||
case 17: return NSPoint(x: w / 2 - 77, y: 28)
|
||||
case 18: return NSPoint(x: w / 2 - 60, y: 28)
|
||||
default: return nil
|
||||
}
|
||||
guard let view, !view.bounds.isEmpty else { return nil }
|
||||
return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil)
|
||||
}
|
||||
|
||||
static func interceptDrags(in window: NSWindow) {
|
||||
target = window
|
||||
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
|
||||
let original = unsafeBitCast(method_getImplementation(method),
|
||||
to: (@convention(c) (NSWindow, Selector, NSEvent) -> Void).self)
|
||||
let handler: @convention(block) (NSWindow, NSEvent) -> Void = { window, event in
|
||||
MainActor.assumeIsolated {
|
||||
if window === target && dragDepth == 0 { drags += 1 }
|
||||
dragDepth += 1
|
||||
original(window, #selector(NSWindow.performDrag(with:)), event)
|
||||
dragDepth -= 1
|
||||
}
|
||||
}
|
||||
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
|
||||
// SwiftUI uses AppKit's completion-taking entry point on newer macOS releases.
|
||||
let selector = NSSelectorFromString("performWindowDragWithEvent:completionHandler:")
|
||||
if let method = class_getInstanceMethod(type(of: window), selector) {
|
||||
let original = unsafeBitCast(method_getImplementation(method),
|
||||
to: (@convention(c) (NSWindow, Selector, NSEvent, AnyObject?) -> Void).self)
|
||||
let handler: @convention(block) (NSWindow, NSEvent, AnyObject?) -> Void = { window, event, done in
|
||||
MainActor.assumeIsolated {
|
||||
if window === target && dragDepth == 0 { drags += 1 }
|
||||
dragDepth += 1
|
||||
original(window, selector, event, done)
|
||||
dragDepth -= 1
|
||||
}
|
||||
}
|
||||
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
|
||||
}
|
||||
}
|
||||
|
||||
static func advance() {
|
||||
guard ContinuousClock.now < deadline else {
|
||||
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
|
||||
}
|
||||
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
let model = PlayerModel.shared
|
||||
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
|
||||
if target == nil {
|
||||
guard model.canTogglePlayback else { return }
|
||||
interceptDrags(in: window)
|
||||
model.setVolume(0)
|
||||
model.setPlayback(playing: false)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
if phase == 0 {
|
||||
guard NSApp.isActive, window.isKeyWindow else {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
model.setVolume(0)
|
||||
switch probe {
|
||||
case 5: model.closeFile()
|
||||
case 8:
|
||||
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
|
||||
let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272),
|
||||
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber, context: nil, eventNumber: type == .leftMouseDown ? 1 : 2,
|
||||
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
|
||||
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
|
||||
}
|
||||
case 9:
|
||||
model.open(fixtures.appendingPathComponent("cache.mp4"))
|
||||
model.showsQueue = true
|
||||
case 13:
|
||||
model.showsQueue = false
|
||||
model.openYouTube("not a video URL")
|
||||
case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
|
||||
case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
|
||||
case 19: model.hideControls(hideCursor: false)
|
||||
default: break
|
||||
}
|
||||
previousDrags = drags
|
||||
} else if phase == 1 {
|
||||
guard !model.isQueueTransitioning, !model.compactWindow.isTransitioning, !model.isBusy else { return }
|
||||
if probe == 16 && !model.isCompact { model.toggleCompact(); return }
|
||||
// Posted events do not move the physical pointer into the compact window.
|
||||
if probe != 19 { model.setPointerInsideWindow(true) }
|
||||
guard let point = point(in: window) else { return }
|
||||
start = point
|
||||
} else if (2...4).contains(phase) {
|
||||
let type: NSEvent.EventType = phase == 2 ? .leftMouseDown : (phase == 3 ? .leftMouseDragged : .leftMouseUp)
|
||||
let point = phase == 2 ? start : NSPoint(x: start.x + 20, y: start.y + 48)
|
||||
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
|
||||
context: nil, eventNumber: probe * 8 + phase, clickCount: 1, pressure: phase == 4 ? 0 : 1)!
|
||||
let native = event.cgEvent!
|
||||
if type == .leftMouseDragged {
|
||||
native.setDoubleValueField(.mouseEventDeltaX, value: 20)
|
||||
native.setDoubleValueField(.mouseEventDeltaY, value: -48)
|
||||
}
|
||||
NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false)
|
||||
} else if phase == 6 {
|
||||
let expected = controls.contains(probe) ? 0 : 1
|
||||
guard drags - previousDrags == expected else {
|
||||
finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return
|
||||
}
|
||||
probe += 1
|
||||
if probe == names.count { finish(); return }
|
||||
phase = 0
|
||||
return
|
||||
}
|
||||
phase += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum QueueDragCheck {
|
||||
static var timer: Timer?
|
||||
|
||||
@@ -211,8 +211,6 @@ struct CompactWindowChecks {
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
|
||||
"compact uses 160x90 content minimum")
|
||||
try CompactWindowChecks.require(window.isMovableByWindowBackground,
|
||||
"compact allows background dragging")
|
||||
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
|
||||
try CompactWindowChecks.require(window.titleVisibility == .hidden
|
||||
&& window.titlebarAppearsTransparent,
|
||||
|
||||
@@ -47,9 +47,9 @@ struct SystemPlaybackChecks {
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
let surface = PlaybackSurface.DragView(model: model)
|
||||
surface.frame = window.contentView!.bounds
|
||||
window.contentView!.addSubview(surface)
|
||||
model.attachWindow(window)
|
||||
surface.frame = window.contentView!.bounds
|
||||
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
|
||||
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
|
||||
@@ -61,7 +61,7 @@ Compact entry and exit animate asynchronously over 0.25 seconds, preserving the
|
||||
|
||||
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
|
||||
|
||||
Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video.
|
||||
Drag the video or any noninteractive background to move the window, including the start screen, queue, playback chrome, loading/error screens, audio-only playback, casting and compact mode. Buttons, menus, sliders, editable/selectable text and queue rows retain their own mouse input. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video. Native edges remain available for resizing; macOS fullscreen keeps its fixed window bounds.
|
||||
|
||||
Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention.
|
||||
|
||||
|
||||
@@ -334,7 +334,8 @@ final class CompactWindowController: NSObject {
|
||||
window.collectionBehavior = behavior
|
||||
window.level = .floating
|
||||
window.hidesOnDeactivate = false
|
||||
window.isMovableByWindowBackground = true
|
||||
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
|
||||
window.isMovableByWindowBackground = false
|
||||
fitCompactWindow(window, initial: true, animated: animated)
|
||||
}
|
||||
|
||||
|
||||
@@ -2101,6 +2101,7 @@ private struct PlayerContent: View {
|
||||
@ViewState private var queueFrames: [UUID: CGRect] = [:]
|
||||
@ViewState private var queueDrag: PlaybackQueueDrag?
|
||||
@GestureState private var queueDragging = false
|
||||
@GestureState private var windowDragging = false
|
||||
@ViewState private var queueScroll = ScrollPosition(y: 0)
|
||||
@ViewState private var queueScrollOffset: CGFloat = 0
|
||||
@ViewState private var queueViewportHeight: CGFloat = 0
|
||||
@@ -2228,6 +2229,8 @@ private struct PlayerContent: View {
|
||||
.padding(.horizontal, size.width < 600 ? 14 : 30)
|
||||
.padding(.top, 64)
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, minHeight: size.height, alignment: .topLeading)
|
||||
.background(windowDragBackground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -2337,6 +2340,7 @@ private struct PlayerContent: View {
|
||||
.padding(.trailing, 92 - 88 * model.queueWidth / 260)
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.background(windowDragBackground)
|
||||
.onHover(perform: model.setTitleHovered)
|
||||
.animation(controlsVisibilityAnimation) { content in
|
||||
content.opacity(model.controlsVisible ? 1 : 0)
|
||||
@@ -2357,6 +2361,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.padding(.horizontal, 4 * (1 - model.queueWidth / 260))
|
||||
.frame(height: 24)
|
||||
.background(windowDragBackground)
|
||||
.background {
|
||||
PlayerGlass(cornerRadius: 8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
@@ -2434,6 +2439,7 @@ private struct PlayerContent: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, showsBufferReadout ? 6 : 8)
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.background(windowDragBackground)
|
||||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||
.onHover(perform: model.setControlsHovered)
|
||||
.animation(controlsVisibilityAnimation) { content in
|
||||
@@ -2673,6 +2679,8 @@ private struct PlayerContent: View {
|
||||
.highPriorityGesture(queueRowDrag(item))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: queueViewportHeight, alignment: .top)
|
||||
.background(windowDragBackground)
|
||||
.coordinateSpace(name: "queueContent")
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 }
|
||||
}
|
||||
@@ -2743,6 +2751,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(windowDragBackground)
|
||||
.background { queueChromeBackground(top: false) }
|
||||
}
|
||||
.frame(width: 260)
|
||||
@@ -2794,6 +2803,14 @@ private struct PlayerContent: View {
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.tint(Color(SunsetPalette.accent))
|
||||
.background(WindowChrome(model: model))
|
||||
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
|
||||
}
|
||||
|
||||
private var windowDragBackground: some View {
|
||||
Color.clear.contentShape(Rectangle())
|
||||
.gesture(WindowDragGesture()
|
||||
.updating($windowDragging) { _, active, _ in active = true })
|
||||
.padding(5) // Preserve the native resize border.
|
||||
}
|
||||
|
||||
private var videoContent: some View {
|
||||
@@ -2837,6 +2854,7 @@ private struct PlayerContent: View {
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||
.padding(.top, 44).padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
if let error = model.errorText {
|
||||
VStack(spacing: 12) {
|
||||
@@ -2849,6 +2867,7 @@ private struct PlayerContent: View {
|
||||
.frame(maxWidth: 420)
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(windowDragBackground)
|
||||
} else if model.isBusy {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
@@ -2857,6 +2876,7 @@ private struct PlayerContent: View {
|
||||
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
} else if model.fileName == nil {
|
||||
startScreen(in: geometry.size)
|
||||
} else if let device = model.castDevice {
|
||||
@@ -2867,6 +2887,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(windowDragBackground)
|
||||
} else if !model.state.has_video && model.state.has_audio {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
|
||||
@@ -2875,6 +2896,7 @@ private struct PlayerContent: View {
|
||||
.foregroundStyle(Color(SunsetPalette.secondaryText))
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
if model.state.buffering && !model.isBusy && model.errorText == nil {
|
||||
ProgressView("Buffering…")
|
||||
@@ -2928,6 +2950,7 @@ private struct PlayerContent: View {
|
||||
if model.canTogglePlayback {
|
||||
playbackControls
|
||||
.frame(width: 440, height: controlHeight)
|
||||
.background(windowDragBackground)
|
||||
.background {
|
||||
PlayerGlass()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification
|
||||
- [x] make the sidebar expand/collapse with a nice transition
|
||||
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
|
||||
- [ ] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
|
||||
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
|
||||
- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
|
||||
- scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification
|
||||
- [ ] on the start screen:
|
||||
- [ ] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
|
||||
- [ ] change the youtube button to general streaming
|
||||
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)
|
||||
Reference in New Issue
Block a user