feat: add editable playback queue

This commit is contained in:
2026-10-18 00:15:05 +02:00
parent d4704af442
commit ce903e2367
10 changed files with 510 additions and 33 deletions
+34
View File
@@ -17,6 +17,36 @@ struct CastPlaybackCheck {
require(predicate(), message) require(predicate(), message)
} }
static func queued(_ model: PlayerModel, source: URL, port: UInt16, gates: URL) async throws {
model.openFiles([source, source, source])
try await until(model, "queue local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
try await until(model, "queue receiver starts") {
model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING
}
let initial = model.queue.currentID
model.advanceQueue(1)
try await until(model, "manual queue next retains receiver") {
model.queue.currentID != initial && model.castDevice?.id == "loopback" &&
!model.castConnecting && model.state.status == PLAYER_PLAYING
}
let middle = model.queue.currentID
try Data().write(to: gates.appendingPathComponent("next"), options: .atomic)
try await until(model, "receiver EOF automatically advances queue without disconnecting") {
model.queue.currentID != middle && model.castDevice?.id == "loopback" &&
!model.castConnecting && model.state.status == PLAYER_PLAYING
}
require(!model.queue.canNext, "receiver automatic advance selects final queue entry")
try Data().write(to: gates.appendingPathComponent("advanced"), options: .atomic)
try await until(model, "final receiver EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
require(model.castDevice?.id == "loopback" && !model.queue.canNext,
"final receiver EOF preserves receiver and last selection")
model.stopCasting()
try await until(model, "Cast restoration keeps local queue") { model.castDevice == nil && !model.isBusy }
require(model.queue.items.count == 3 && !model.queue.canNext,
"Cast restoration does not replace queue")
}
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16, static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
scenario: String, gates: URL, defaults: UserDefaults) async throws { scenario: String, gates: URL, defaults: UserDefaults) async throws {
func signal(_ name: String) throws { func signal(_ name: String) throws {
@@ -183,6 +213,10 @@ struct CastPlaybackCheck {
let model = PlayerModel(defaults: defaults) let model = PlayerModel(defaults: defaults)
defer { model.shutdown() } defer { model.shutdown() }
if CommandLine.arguments.count == 5 { if CommandLine.arguments.count == 5 {
if CommandLine.arguments[3] == "queue" {
try await queued(model, source: source, port: port, gates: URL(fileURLWithPath: CommandLine.arguments[4]))
return
}
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3], try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults) gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
return return
+32
View File
@@ -791,6 +791,36 @@ struct CastSessionCheck {
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes") require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
} }
static func queuedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
let peer = try Receiver(identity: identity)
peer.verifyRelay = true
try await peer.start()
defer { peer.stop() }
let gates = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-queue-" + UUID().uuidString)
try FileManager.default.createDirectory(at: gates, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: gates) }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port), "queue", gates.path]
try process.run()
defer { if process.isRunning { process.terminate() } }
for stage in ["next", "advanced"] {
try await until("queue consumer reaches \(stage)", attempts: 18000) {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(stage).path) ||
!process.isRunning || peer.failure != nil
}
require(peer.failure == nil && process.isRunning, peer.failure ?? "queue consumer remains active")
require(FileManager.default.fileExists(atPath: gates.appendingPathComponent(stage).path),
"queue consumer acknowledges \(stage)")
try peer.status(state: "IDLE", idle: "FINISHED")
}
try await until("queue consumer completes", attempts: 18000) { !process.isRunning || peer.failure != nil }
require(peer.failure == nil && !process.isRunning && process.terminationStatus == 0,
peer.failure ?? "queue navigation and EOF retain receiver")
require(peer.commands.filter { $0.0 == "LOAD" }.count == 3 && peer.relayVerified,
"initial, manual-next and automatic-next LOADs fetch real relay media")
}
static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws { static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load", for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
"replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] { "replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] {
@@ -913,6 +943,8 @@ struct CastSessionCheck {
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect") print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3]) try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery") print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery")
try await queuedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: Cast queue next, automatic advance, final EOF and local restoration")
} else { } else {
try await behavior(identity: identity) try await behavior(identity: identity)
try await seekCoalescing(identity: identity) try await seekCoalescing(identity: identity)
+71
View File
@@ -0,0 +1,71 @@
import Foundation
@main
struct PlaybackQueueChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func file(_ name: String) -> URL {
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
}
static func main() {
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
var queue = PlaybackQueue()
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,
"empty queue has no selection or navigation")
require(queue.advance(1) == nil && queue.advance(-1) == nil, "empty navigation is harmless")
queue.replace([tenth, second, first])
require(queue.items.map(\.url) == [first, second, tenth], "replace uses natural Episode2/Episode10 filename order")
require(queue.currentURL == first && !queue.canPrevious && queue.canNext, "replace selects first item")
let firstID = queue.currentID
require(queue.advance(-1) == nil && queue.currentID == firstID, "previous at start does not wrap or deselect")
require(queue.advance(1) == second && queue.canPrevious && queue.canNext, "next selects middle item")
require(queue.advance(1) == tenth && queue.canPrevious && !queue.canNext, "next selects final item")
let tenthID = queue.currentID
require(queue.advance(1) == nil && queue.currentID == tenthID, "next at end stops without wrapping")
require(queue.advance(-1) == second, "previous moves back one item")
let selected = queue.currentID!
queue.append([file("Episode20"), file("Episode3")])
require(queue.items.map(\.url) == [first, second, tenth, file("Episode3"), file("Episode20")],
"append naturally sorts the new batch without reordering existing items")
require(queue.currentID == selected && queue.currentURL == second, "append does not interrupt selection")
queue.move(fromOffsets: IndexSet(integer: 1), toOffset: queue.items.count)
require(queue.items.last?.id == selected && queue.currentID == selected && queue.currentURL == second,
"reordering preserves current item identity")
require(queue.canPrevious && !queue.canNext, "navigation follows reordered positions")
queue.move(fromOffsets: IndexSet([0, 1]), toOffset: queue.items.count)
require(queue.currentID == selected && queue.currentURL == second, "moving other items preserves current")
queue.replace([tenth, second, first])
queue.select(queue.items[1].id)
require(queue.currentURL == second, "explicit selection jumps to an item")
let current = queue.currentID
let unrelated = queue.items[0].id
require(queue.remove(unrelated) == nil && queue.currentID == current && queue.currentURL == second,
"removing another item does not request playback or change selection")
require(queue.remove(current!) == tenth && queue.currentURL == tenth,
"removing current selects the next remaining item")
require(queue.remove(queue.currentID!) == nil && queue.currentURL == nil && queue.items.isEmpty,
"removing final item stops and empties queue")
queue.replace([first, second, tenth])
queue.select(queue.items.last!.id)
require(queue.remove(queue.currentID!) == nil && queue.currentID == nil && queue.items.count == 2,
"removing current at end stops rather than jumping backward")
require(!queue.canPrevious && !queue.canNext, "stopped queue has no implicit selection")
queue.select(queue.items[0].id)
require(queue.currentURL == first, "remaining items can be explicitly selected after stopping")
queue.append([first])
require(Set(queue.items.map(\.id)).count == queue.items.count, "duplicate file entries have independent identities")
queue.replace([tenth])
require(queue.items.map(\.url) == [tenth] && queue.currentURL == tenth && !queue.canPrevious && !queue.canNext,
"replace discards previous queue and starts new selection")
queue.replace([])
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
print("playback queue checks passed")
}
}
+50
View File
@@ -51,6 +51,54 @@ struct SystemPlaybackChecks {
"disabled compact progress leaves only the title") "disabled compact progress leaves only the title")
} }
static func checkQueue(directory: URL, defaults: UserDefaults) {
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
model.setVolume(0)
let video = directory.appendingPathComponent("av.mp4")
let audio = directory.appendingPathComponent("audio.wav")
model.openFiles([video, audio])
until("queue opens its naturally sorted first file") {
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
}
let current = model.queue.currentID!
require(model.setPlayback(playing: false), "pause queue before editing")
until("queue pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
let revision = model.state.revision
model.addToQueue([video])
model.moveQueueItems(fromOffsets: IndexSet(integer: 0), toOffset: 2)
require(model.queue.currentID == current && model.state.revision == revision &&
model.state.status == PLAYER_PAUSED, "append and reorder do not reopen current playback")
model.removeQueueItem(model.queue.items.last!.id)
require(model.queue.currentID == current && model.state.revision == revision,
"removing another queue entry leaves native playback untouched")
model.removeQueueItem(current)
until("removing final current entry stops despite earlier remaining entries") {
model.queue.currentID == nil && model.state.status == PLAYER_EMPTY
}
require(model.queue.items.count == 1, "stopping at queue end preserves earlier entries")
model.selectQueueItem(model.queue.items[0].id)
until("explicit queue selection opens remaining entry") {
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
}
model.openFiles([video, audio])
until("replacement queue starts with audio") {
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
}
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near first EOF")
until("natural EOF advances to next queue file") {
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
}
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near final EOF")
until("final queue EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
require(model.queue.currentURL == video && !model.queue.canNext, "final EOF retains last selection")
model.openFiles([directory.appendingPathComponent("hdr.mkv"), video])
model.selectQueueItem(model.queue.items.first { $0.url.lastPathComponent == "hdr.mkv" }!.id)
until("queued playback error is acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(model.queue.currentURL?.lastPathComponent == "hdr.mkv",
"playback failure stops on failed queue entry rather than skipping")
}
static func main() { static func main() {
checkCompactProgress() checkCompactProgress()
_ = NSApplication.shared _ = NSApplication.shared
@@ -58,6 +106,8 @@ struct SystemPlaybackChecks {
let suite = "terebi.system-playback-check.\(UUID().uuidString)" let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
defaults.removePersistentDomain(forName: suite)
let initialModel = PlayerModel(defaults: defaults) let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback") require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
initialModel.setShowsBufferInfo(true) initialModel.setShowsBufferInfo(true)
+9 -3
View File
@@ -13,7 +13,7 @@ PREFIX ?= $(HOME)/.local
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE) CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
CONFIG_BUILD := .build/configuration CONFIG_BUILD := .build/configuration
@@ -28,7 +28,7 @@ BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
SIGNING_IDENTITY ?= - SIGNING_IDENTITY ?= -
DECODE_FIXTURES := $(addprefix $(BUILD_DIR)/fixtures/decode-,h264.mp4 hevc.mp4 vp9.mp4 av1.mp4 h264-10.mp4 mpeg4.mp4) DECODE_FIXTURES := $(addprefix $(BUILD_DIR)/fixtures/decode-,h264.mp4 hevc.mp4 vp9.mp4 av1.mp4 h264-10.mp4 mpeg4.mp4)
.PHONY: all release install check-dependencies run check check-seek-queue check-video-decoding check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast .PHONY: all release install check-dependencies run check check-seek-queue check-video-decoding check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
all: $(BUILD_DIR)/terebi all: $(BUILD_DIR)/terebi
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
@@ -140,7 +140,7 @@ $(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sou
check-seek-queue: $(BUILD_DIR)/seek-queue-check check-seek-queue: $(BUILD_DIR)/seek-queue-check
$(BUILD_DIR)/seek-queue-check $(BUILD_DIR)/seek-queue-check
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies check-fixtures: | check-dependencies
@@ -200,6 +200,12 @@ $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App
check-quality: $(BUILD_DIR)/automatic-quality-check check-quality: $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-check $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/playback-queue-check: Checks/playback-queue.swift Sources/App/PlaybackQueue.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/playback-queue.swift Sources/App/PlaybackQueue.swift -framework Foundation -o $@
check-queue: $(BUILD_DIR)/playback-queue-check
$(BUILD_DIR)/playback-queue-check
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
+11 -3
View File
@@ -31,7 +31,7 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
## Usage ## Usage
Open a file with **⌘O**, drag it onto the window, or use Finder's **Open With**. Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
| Action | Default shortcut | | Action | Default shortcut |
| --- | --- | | --- | --- |
@@ -53,6 +53,14 @@ Playback controls stay anchored at the bottom center of the video and cannot be
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. 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.
### Local-file queue
Use the titlebar's **Queue** icon or **View → Show Queue** to show the editable sidebar. **Add to Queue…** and dropping files onto the sidebar append without replacing the queue or interrupting the current item. Drag rows to reorder, remove them with the minus button, and double-click a row to play it. The current item is marked; context menus also offer Play, Remove and Move Up/Down.
Playback advances automatically and stops at the end, without wrapping. **Previous Queue Item** and **Next Queue Item** are available in the sidebar and Playback menu and are disabled at their respective ends. Reordering or removing another item does not interrupt playback; removing the current item plays its next neighbor, or stops if there is none. A playback error stays on that item rather than skipping it.
The queue contains local files only and lasts until it is replaced, closed or the app quits; it has no persistence, shuffle, repeat or playlist-file import. Opening a YouTube video or resolved stream clears it. The sidebar is hidden in compact mode; Playback-menu queue navigation remains available. Sidebar appearance, drag/drop interaction and physical-receiver queue transitions still need manual verification.
### YouTube ### YouTube
Playback defaults to the best available SDR resolution up to **1080p**. Use **gear → Quality** to select another available resolution, including 4K, or enable automatic quality (off by default). **Show Buffer Info** displays the retained network buffer. Playback defaults to the best available SDR resolution up to **1080p**. Use **gear → Quality** to select another available resolution, including 4K, or enable automatic quality (off by default). **Show Buffer Info** displays the retained network buffer.
@@ -63,7 +71,7 @@ Only individual videos are supported, not playlists or channels. Private, age-re
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback. Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position. Opening different media ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support. Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position without changing the local-file queue. Queue navigation and automatic advance keep the selected receiver; opening a replacement selection or different stream ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support.
External Cast subtitles use white sans-serif text with a black outline, transparent background and no enclosing window. Font scale currently uses the receiver default (1.0), pending physical-TV tuning. Language selection and Off remain independent of video delivery; no subtitle burn-in is used. External Cast subtitles use white sans-serif text with a black outline, transparent background and no enclosing window. Font scale currently uses the receiver default (1.0), pending physical-TV tuning. Language selection and Off remain independent of video delivery; no subtitle burn-in is used.
@@ -129,7 +137,7 @@ Settings load once at startup. Menu changes are session-only and never write the
## Limits ## Limits
- SDR output only; PQ/HLG HDR is rejected. No playlists, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. External resolved-source WebVTT subtitles are supported. - SDR output only; PQ/HLG HDR is rejected. No stream playlists, playlist-file import, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. The session-only local-file queue and external resolved-source WebVTT subtitles are supported.
- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media. - H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media.
- Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed. - Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed.
- Audio-device changes during playback are not handled. - Audio-device changes during playback are not handled.
+62
View File
@@ -0,0 +1,62 @@
import Foundation
struct PlaybackQueue {
struct Item: Identifiable {
let id = UUID()
let url: URL
}
private(set) var items: [Item] = []
private(set) var currentID: UUID?
var currentURL: URL? { items.first { $0.id == currentID }?.url }
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
mutating func replace(_ urls: [URL]) {
items = sortedFiles(urls).map { Item(url: $0) }
currentID = items.first?.id
}
mutating func append(_ urls: [URL]) {
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
}
mutating func select(_ id: UUID) {
guard items.contains(where: { $0.id == id }) else { return }
currentID = id
}
mutating func advance(_ offset: Int) -> URL? {
guard let index = currentIndex else { return nil }
let (target, overflow) = index.addingReportingOverflow(offset)
guard !overflow, items.indices.contains(target) else { return nil }
currentID = items[target].id
return items[target].url
}
mutating func remove(_ id: UUID) -> URL? {
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
let wasCurrent = currentID == id
items.remove(at: index)
guard wasCurrent else { return nil }
currentID = items.indices.contains(index) ? items[index].id : nil
return currentURL
}
mutating func move(fromOffsets offsets: IndexSet, toOffset destination: Int) {
guard !offsets.isEmpty, offsets.allSatisfy(items.indices.contains),
(0...items.count).contains(destination) else { return }
let moving = offsets.map { items[$0] }
for index in offsets.reversed() { items.remove(at: index) }
let insertion = destination - offsets.filter { $0 < destination }.count
items.insert(contentsOf: moving, at: insertion)
}
private func sortedFiles(_ urls: [URL]) -> [URL] {
urls.filter(\.isFileURL).sorted {
let order = $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent)
return order == .orderedSame ? $0.path < $1.path : order == .orderedAscending
}
}
}
+8
View File
@@ -178,6 +178,8 @@ struct PlayerCommands: Commands {
Button("Open File…") { model.performMenuAction(.openFile) } Button("Open File…") { model.performMenuAction(.openFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
.disabled(!model.canPerform(.openFile)) .disabled(!model.canPerform(.openFile))
Button("Add to Queue…", action: model.addQueuePanel)
.disabled(!model.canOpen)
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) } Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube)) .disabled(!model.canPerform(.openYoutube))
@@ -195,6 +197,10 @@ struct PlayerCommands: Commands {
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) } Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback)) .disabled(!hasFocusedScene || !model.canPerform(playback))
Button("Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!hasFocusedScene || !model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }
.disabled(!hasFocusedScene || !model.canNextQueueItem)
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) } Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s)) .disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
@@ -256,6 +262,8 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo)) .disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
} }
CommandGroup(replacing: .sidebar) { CommandGroup(replacing: .sidebar) {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
.disabled(!hasFocusedScene || model.isCompact)
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") { Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen) model.performMenuAction(fullscreen)
} }
+231 -25
View File
@@ -31,6 +31,13 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileName: String? @Published private(set) var fileName: String?
@Published private(set) var fileIcon: NSImage? @Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String? @Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue()
@Published var showsQueue = false
private var queuePlaybackRevision: UInt64?
private var queueCompletionHandled = false
private var queueCastDevice: CastDevice?
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
var canNextQueueItem: Bool { canOpen && queue.canNext }
private var transition = PlaybackTransition() private var transition = PlaybackTransition()
let castDiscovery = CastDiscovery() let castDiscovery = CastDiscovery()
private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) } private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
@@ -193,17 +200,18 @@ final class PlayerModel: ObservableObject {
} }
} }
func cast(to device: CastDevice) { func cast(to device: CastDevice, playing: Bool? = nil) {
guard canCast, let source = currentMedia, let handle else { return } guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution() cancelYouTubeResolution()
commandError = nil commandError = nil
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle, casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: effectivePlaying, handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume) volume: volume, audibleVolume: lastAudibleVolume)
revealControls() revealControls()
} }
func stopCasting(resume: Bool = true) { func stopCasting(resume: Bool = true) {
queueCastDevice = nil
casting.stop(resume: resume) casting.stop(resume: resume)
} }
@@ -224,6 +232,7 @@ final class PlayerModel: ObservableObject {
if !isSeeking && !state.seek_pending { seekPosition = state.position } if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) } if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress() saveProgress()
completeQueuePlaybackIfNeeded()
case .stopped(let result): case .stopped(let result):
if let checkpoint = result.checkpoint { if let checkpoint = result.checkpoint {
saveCastProgress(checkpoint, source: result.source) saveCastProgress(checkpoint, source: result.source)
@@ -253,17 +262,21 @@ final class PlayerModel: ObservableObject {
return accepted return accepted
} }
func openPanel() { func openPanel() { filePanel(append: false) }
func addQueuePanel() { filePanel(append: true) }
private func filePanel(append: Bool) {
guard canOpen else { return } guard canOpen else { return }
defer { window?.makeFirstResponder(nil) } defer { window?.makeFirstResponder(nil) }
revealControls() revealControls()
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.canChooseFiles = true panel.canChooseFiles = true
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = true
panel.prompt = "Open" panel.prompt = append ? "Add to Queue" : "Open"
guard panel.runModal() == .OK, let url = panel.url else { return } guard panel.runModal() == .OK else { return }
open(url) if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
} }
func openYouTubePanel() { func openYouTubePanel() {
@@ -294,12 +307,14 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
return return
} }
clearQueue()
resolveYouTube(url, height: nil) resolveYouTube(url, height: nil)
} }
func openResolvedSource(_ file: URL) { func openResolvedSource(_ file: URL) {
do { do {
let source = try ResolvedSource.load(from: file) let source = try ResolvedSource.load(from: file)
clearQueue()
cancelYouTubeResolution() cancelYouTubeResolution()
openMedia(.resolved(source)) openMedia(.resolved(source))
} catch { } catch {
@@ -487,6 +502,8 @@ final class PlayerModel: ObservableObject {
return return
} }
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition) transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false
currentMedia = source currentMedia = source
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source { switch source {
@@ -528,13 +545,79 @@ final class PlayerModel: ObservableObject {
} }
func open(_ url: URL) { func open(_ url: URL) {
guard canOpen else { return } if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
} }
func openFiles(_ urls: [URL]) {
guard canOpen, urls.contains(where: \.isFileURL) else { return }
queueCastDevice = nil
queue.replace(urls)
showsQueue = queue.items.count > 1
openQueueSelection(keepingReceiver: false)
}
func addToQueue(_ urls: [URL]) {
guard canOpen else { return }
let wasEmpty = queue.items.isEmpty
queue.append(urls)
guard !queue.items.isEmpty else { return }
showsQueue = true
if wasEmpty, fileName == nil, let first = queue.items.first {
queue.select(first.id)
openQueueSelection()
}
}
func selectQueueItem(_ id: UUID) {
guard canOpen, queue.items.contains(where: { $0.id == id }) else { return }
queue.select(id)
openQueueSelection()
}
func advanceQueue(_ offset: Int) {
guard canOpen, queue.advance(offset) != nil else { return }
openQueueSelection()
}
func removeQueueItem(_ id: UUID) {
let wasCurrent = queue.currentID == id
let next = queue.remove(id)
guard wasCurrent else { return }
if next != nil { openQueueSelection() } else { closeFile(clearQueue: false) }
}
func moveQueueItems(fromOffsets offsets: IndexSet, toOffset destination: Int) {
queue.move(fromOffsets: offsets, toOffset: destination)
}
private func openQueueSelection(keepingReceiver: Bool = true) {
guard let url = queue.currentURL else { return }
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
queuePlaybackRevision = nil
queueCompletionHandled = true
cancelYouTubeResolution() cancelYouTubeResolution()
openMedia(.file(url)) openMedia(.file(url), change: .queue(playing: receiver == nil))
queueCastDevice = receiver
}
private func clearQueue() {
queue.replace([])
queuePlaybackRevision = nil
queueCompletionHandled = false
queueCastDevice = nil
}
private func completeQueuePlaybackIfNeeded() {
guard queue.currentID != nil, queuePlaybackRevision != nil, !isBusy else { return }
if state.status == PLAYER_PLAYING { queueCompletionHandled = false }
guard !queueCompletionHandled, !state.seek_pending, !transition.seekPending else { return }
if state.status == PLAYER_ERROR {
queueCompletionHandled = true
queueCastDevice = nil
} else if state.status == PLAYER_ENDED {
queueCompletionHandled = true
if queue.canNext { advanceQueue(1) }
}
} }
func togglePlayback() { func togglePlayback() {
@@ -582,7 +665,7 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
} }
func closeFile() { func closeFile(clearQueue: Bool = true) {
guard let handle, canClose else { return } guard let handle, canClose else { return }
saveProgress() saveProgress()
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
@@ -593,6 +676,10 @@ final class PlayerModel: ObservableObject {
return return
} }
transition.closeMedia() transition.closeMedia()
if clearQueue { self.clearQueue() }
queuePlaybackRevision = nil
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil currentMedia = nil
selectSubtitle(nil) selectSubtitle(nil)
subtitleTracks = [] subtitleTracks = []
@@ -922,6 +1009,8 @@ final class PlayerModel: ObservableObject {
} }
func applyState(_ next: PlayerState) { func applyState(_ next: PlayerState) {
guard !casting.active else { return }
if let revision = queuePlaybackRevision, next.revision < revision { return }
let statusChanged = state.status != next.status let statusChanged = state.status != next.status
let observation = transition.advance(next, let observation = transition.advance(next,
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED), forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
@@ -945,6 +1034,15 @@ final class PlayerModel: ObservableObject {
if let position = restored.position { seekPosition = position } if let position = restored.position { seekPosition = position }
commandError = restored.error commandError = restored.error
} }
if let device = queueCastDevice, !isBusy, !transition.seekPending, !state.seek_pending {
if state.status == PLAYER_ERROR {
queueCastDevice = nil
} else if canCast {
queueCastDevice = nil
cast(to: device, playing: true)
}
}
completeQueuePlaybackIfNeeded()
} }
private func updateAutomaticQuality() { private func updateAutomaticQuality() {
@@ -1246,6 +1344,7 @@ final class PlayerModel: ObservableObject {
private enum MediaChange { private enum MediaChange {
case open case open
case queue(playing: Bool)
case quality(position: Double?, playing: Bool, automatic: Bool) case quality(position: Double?, playing: Bool, automatic: Bool)
case restore(position: Double, playing: Bool) case restore(position: Double, playing: Bool)
@@ -1256,6 +1355,7 @@ final class PlayerModel: ObservableObject {
var playing: Bool { var playing: Bool {
if case .quality(_, let playing, _) = self { return playing } if case .quality(_, let playing, _) = self { return playing }
if case .restore(_, let playing) = self { return playing } if case .restore(_, let playing) = self { return playing }
if case .queue(let playing) = self { return playing }
return true return true
} }
var position: Double? { var position: Double? {
@@ -1941,6 +2041,9 @@ private struct PlayerContent: View {
private var titleControls: some View { private var titleControls: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") {
model.showsQueue.toggle()
}
iconButton(model.isOnTop ? "pin.fill" : "pin", iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.imageScale(.small) .imageScale(.small)
@@ -1963,6 +2066,10 @@ private struct PlayerContent: View {
private var actionsMenu: some View { private var actionsMenu: some View {
Menu { Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…") PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…") PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File") PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider() Divider()
@@ -2115,7 +2222,106 @@ private struct PlayerContent: View {
) )
} }
private var queueSidebar: some View {
VStack(spacing: 0) {
HStack {
Text("Queue").font(.headline)
Text("\(model.queue.items.count)").foregroundStyle(.secondary)
Spacer()
Button { model.showsQueue = false } label: {
Image(systemName: "sidebar.right")
}
.help("Hide Queue")
.accessibilityLabel("Hide Queue")
}
.padding(12)
HStack {
iconButton("backward.end.fill", "Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!model.canPreviousQueueItem)
iconButton("forward.end.fill", "Next Queue Item") { model.advanceQueue(1) }
.disabled(!model.canNextQueueItem)
Spacer()
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
List {
ForEach(model.queue.items) { item in
HStack(spacing: 8) {
Image(systemName: item.id == model.queue.currentID ? "play.fill" : "film")
.foregroundStyle(item.id == model.queue.currentID ? Color.accentColor : .secondary)
.accessibilityLabel(item.id == model.queue.currentID ? "Current item" : "Queued item")
Text(item.url.lastPathComponent)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
Button { model.removeQueueItem(item.id) } label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
.help("Remove from Queue")
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.selectQueueItem(item.id) }
.help(item.url.path + "\nDouble-click to play; drag to reorder.")
.contextMenu {
Button("Play") { model.selectQueueItem(item.id) }
Button("Remove from Queue") { model.removeQueueItem(item.id) }
if let index = model.queue.items.firstIndex(where: { $0.id == item.id }) {
Button("Move Up") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1)
}.disabled(index == 0)
Button("Move Down") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2)
}.disabled(index + 1 == model.queue.items.count)
}
}
.accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) }
}
.onMove { model.moveQueueItems(fromOffsets: $0, toOffset: $1) }
}
.listStyle(.sidebar)
.overlay {
if model.queue.items.isEmpty {
Text("Drop local files here to add them to the queue.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding()
.allowsHitTesting(false)
}
}
}
.frame(width: 260)
.padding(.top, 32)
.background(PlayerGlass(cornerRadius: 0))
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, urls.contains(where: \.isFileURL) else { return false }
model.addToQueue(urls)
return true
}
.onHover(perform: model.setControlsHovered)
}
var body: some View { var body: some View {
HStack(spacing: 0) {
videoContent
if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning {
Divider()
queueSidebar
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 400),
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
}
private var videoContent: some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .top) { ZStack(alignment: .top) {
if model.fileName == nil || model.castDevice != nil { if model.fileName == nil || model.castDevice != nil {
@@ -2362,9 +2568,14 @@ private struct PlayerContent: View {
} }
} }
.dropDestination(for: URL.self) { urls, _ in .dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, let url = urls.first else { return false } guard model.canOpen, !urls.isEmpty else { return false }
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false } if urls.allSatisfy(\.isFileURL) {
model.openFiles(urls)
} else {
guard urls.count == 1, let url = urls.first,
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
model.open(url) model.open(url)
}
return true return true
} isTargeted: { } isTargeted: {
dropTargeted = $0 dropTargeted = $0
@@ -2378,13 +2589,6 @@ private struct PlayerContent: View {
} }
} }
} }
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
} }
} }
@@ -2517,8 +2721,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { @objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return } guard let files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return }
PlayerModel.shared.open(url) let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue }
PlayerModel.shared.openFiles(urls)
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
@@ -2538,7 +2743,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Let SwiftUI create the initial window for a Finder/document launch. // Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
if let url = urls.first { PlayerModel.shared.open(url) } if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
else if let url = urls.first { PlayerModel.shared.open(url) }
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
+2 -2
View File
@@ -2,7 +2,7 @@
- native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified - native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified
- [x] currently, computer falls asleep even if video is playing. this should not be the case. - [x] currently, computer falls asleep even if video is playing. this should not be the case.
- native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified - native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
- [ ] add playlist support - [x] add session-only local-file playlist support
- maybe by allowing multiple file selection? 🤔 - multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - [ ] in the "choose what to play" screen, we should 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) - 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)