mirror of
https://github.com/Cuteminded/jellyframe.git
synced 2026-10-28 00:00:27 +01:00
Add manual watched/unwatched toggles
This commit is contained in:
1 parent
ce95b7eb6b
commit
6833640385
10 files changed
+342
-11
No files matched your search
@@ -47,7 +47,9 @@ Implementation does not mean a feature has been tested on every headset.
|
||||
- [x] Resume a movie or episode from its saved position.
|
||||
- [x] Start a partially watched title from the beginning.
|
||||
- [x] Report playback start, progress, pause state, and stop events to Jellyfin.
|
||||
- [ ] Manually mark a movie, episode, season, or series as watched or unwatched.
|
||||
- [x] Manually mark a movie or episode as watched or unwatched.
|
||||
- [x] Manually mark a season as watched or unwatched.
|
||||
- [x] Manually mark a series as watched or unwatched.
|
||||
- [x] Select between multiple media versions
|
||||
- [ ] Browse and play trailers and extras.
|
||||
|
||||
|
||||
@@ -133,6 +133,9 @@ func resume_items() -> Dictionary:
|
||||
func details(id: String) -> Dictionary:
|
||||
return await request("/Users/%s/Items/%s" % [user_id, id.uri_encode()])
|
||||
|
||||
func set_watched(id: String, watched: bool) -> Dictionary:
|
||||
return await request("/Users/%s/PlayedItems/%s" % [user_id.uri_encode(), id.uri_encode()], HTTPClient.METHOD_POST if watched else HTTPClient.METHOD_DELETE)
|
||||
|
||||
func seasons(series_id: String) -> Dictionary:
|
||||
return await request("/Shows/%s/Seasons" % series_id.uri_encode() + query({"UserId": user_id, "Fields": "Overview,UserData", "EnableUserData": "true"}))
|
||||
|
||||
|
||||
+28
-2
@@ -16,6 +16,8 @@ var series_browser: SeriesBrowser
|
||||
var play_button: Button
|
||||
var play_from_start_button: Button
|
||||
var version_button: Button
|
||||
var watched_control: VBoxContainer
|
||||
var resume_label: Label
|
||||
var back_button: Button
|
||||
var title_label: Label
|
||||
var overview_label: Label
|
||||
@@ -30,8 +32,10 @@ func _ready() -> void:
|
||||
artwork.api = api
|
||||
add_child(artwork)
|
||||
var navigation := HBoxContainer.new()
|
||||
navigation.add_theme_constant_override("separation", 12)
|
||||
add_child(navigation)
|
||||
back_button = Widgets.button("Back to library", func(): back_requested.emit(), navigation)
|
||||
back_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -48,6 +52,8 @@ func _ready() -> void:
|
||||
series_browser.api = api
|
||||
series_browser.artwork = artwork
|
||||
series_browser.series_id = str(item.Id)
|
||||
series_browser.series_item = item
|
||||
series_browser.navigation_host = navigation
|
||||
series_browser.selected_season = initial_season
|
||||
series_browser.play_requested.connect(func(episode: Dictionary): play_requested.emit(episode))
|
||||
series_browser.version_requested.connect(func(episode: Dictionary): version_requested.emit(episode))
|
||||
@@ -135,11 +141,31 @@ func _build_playback(parent: VBoxContainer) -> void:
|
||||
if position > 0:
|
||||
play_from_start_button = Widgets.button("Play from start", func(): play_from_start_requested.emit(item), actions)
|
||||
play_from_start_button.custom_minimum_size = Vector2(200, 64)
|
||||
_muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
|
||||
resume_label = _muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
|
||||
var secondary_actions := HFlowContainer.new()
|
||||
secondary_actions.add_theme_constant_override("h_separation", 12)
|
||||
secondary_actions.add_theme_constant_override("v_separation", 12)
|
||||
parent.add_child(secondary_actions)
|
||||
if item.get("Type", "") in ["Movie", "Episode"]:
|
||||
watched_control = preload("res://scripts/ui/watched_control.gd").new()
|
||||
watched_control.api = api
|
||||
watched_control.item = item
|
||||
watched_control.changed.connect(_refresh_playback_state)
|
||||
secondary_actions.add_child(watched_control)
|
||||
watched_control.button.custom_minimum_size.y = 64
|
||||
var sources: Array = item.get("MediaSources", []) if item.get("MediaSources") else []
|
||||
if sources.size() > 1:
|
||||
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), actions)
|
||||
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), secondary_actions)
|
||||
version_button.custom_minimum_size = Vector2(200, 64)
|
||||
version_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
|
||||
func _refresh_playback_state() -> void:
|
||||
var position := int(item.UserData.get("PlaybackPositionTicks", 0))
|
||||
play_button.text = "Resume" if position > 0 else ("Play episode" if item.get("Type") == "Episode" else "Play movie")
|
||||
if is_instance_valid(play_from_start_button): play_from_start_button.visible = position > 0
|
||||
if is_instance_valid(resume_label):
|
||||
resume_label.visible = position > 0
|
||||
resume_label.text = "Continue from " + DetailsMetadata.duration(position)
|
||||
|
||||
func _build_information() -> void:
|
||||
var section := VBoxContainer.new()
|
||||
|
||||
@@ -6,6 +6,10 @@ signal version_requested(item: Dictionary)
|
||||
|
||||
var item: Dictionary
|
||||
var artwork: ArtworkLoader
|
||||
var api: JellyfinClient
|
||||
var watched_control: VBoxContainer
|
||||
var watched_badge: Control
|
||||
var facts_label: Label
|
||||
var play_button: Button
|
||||
var version_button: Button
|
||||
var thumbnail: TextureRect
|
||||
@@ -44,12 +48,12 @@ func _ready() -> void:
|
||||
title_label = _label(DetailsMetadata.title(item, "Episode"), text, 26)
|
||||
var facts := DetailsMetadata.facts(item)
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
if user.get("Played", false):
|
||||
Widgets.watched_badge(frame)
|
||||
watched_badge = Widgets.watched_badge(frame)
|
||||
watched_badge.visible = user.get("Played", false)
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
|
||||
if not facts.is_empty():
|
||||
_label(" / ".join(facts), text, 20).add_theme_color_override("font_color", Color("b0bbce"))
|
||||
facts_label = _label(" / ".join(facts), text, 20)
|
||||
facts_label.add_theme_color_override("font_color", Color("b0bbce"))
|
||||
_label(str(item.get("Overview") if item.get("Overview") else "No description available."), text, 22)
|
||||
play_button = Widgets.button("Resume episode" if position > 0 else "Play episode", func(): chosen.emit(item), image_column)
|
||||
play_button.custom_minimum_size = Vector2(190, 56)
|
||||
@@ -60,6 +64,21 @@ func _ready() -> void:
|
||||
if sources.size() > 1:
|
||||
version_button = Widgets.button("Choose version", func(): version_requested.emit(item), image_column)
|
||||
version_button.accessibility_name = "Choose version of " + title_label.text
|
||||
watched_control = preload("res://scripts/ui/watched_control.gd").new()
|
||||
watched_control.api = api
|
||||
watched_control.item = item
|
||||
watched_control.changed.connect(_refresh_user_data)
|
||||
text.add_child(watched_control)
|
||||
|
||||
func _refresh_user_data() -> void:
|
||||
var user: Dictionary = item.UserData
|
||||
var position := int(user.get("PlaybackPositionTicks", 0))
|
||||
var facts := DetailsMetadata.facts(item)
|
||||
if position > 0: facts.append("Continue from " + DetailsMetadata.duration(position))
|
||||
facts_label.text = " / ".join(facts)
|
||||
watched_badge.visible = user.get("Played", false)
|
||||
play_button.text = "Resume episode" if position > 0 else "Play episode"
|
||||
play_button.accessibility_name = play_button.text + " " + title_label.text + (", watched" if user.get("Played", false) else "")
|
||||
|
||||
func _label(value: String, parent: Node, font_size: int) -> Label:
|
||||
var label := Widgets.label(value, parent, font_size)
|
||||
|
||||
@@ -8,6 +8,7 @@ signal section_changed
|
||||
var api: JellyfinClient
|
||||
var artwork: ArtworkLoader
|
||||
var series_id := ""
|
||||
var series_item: Dictionary = {}
|
||||
var season_grid: HFlowContainer
|
||||
var season_section: VBoxContainer
|
||||
var episode_section: VBoxContainer
|
||||
@@ -17,6 +18,10 @@ var season_description: Label
|
||||
var all_seasons_button: Button
|
||||
var generation := 0
|
||||
var selected_season: Dictionary = {}
|
||||
var navigation_host: HBoxContainer
|
||||
var navigation: HBoxContainer
|
||||
var watched_host: VBoxContainer
|
||||
var watched_control: VBoxContainer
|
||||
|
||||
func _ready() -> void:
|
||||
add_theme_constant_override("separation", 20)
|
||||
@@ -31,9 +36,18 @@ func _ready() -> void:
|
||||
episode_section = VBoxContainer.new()
|
||||
episode_section.add_theme_constant_override("separation", 18)
|
||||
add_child(episode_section)
|
||||
var navigation := HBoxContainer.new()
|
||||
episode_section.add_child(navigation)
|
||||
navigation = HBoxContainer.new()
|
||||
navigation.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
navigation.add_theme_constant_override("separation", 12)
|
||||
(navigation_host if navigation_host != null else self).add_child(navigation)
|
||||
all_seasons_button = Widgets.button("All seasons", _show_seasons, navigation)
|
||||
all_seasons_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
navigation.add_child(spacer)
|
||||
watched_host = VBoxContainer.new()
|
||||
navigation.add_child(watched_host)
|
||||
navigation.hide()
|
||||
episode_heading = _label("", episode_section, 28)
|
||||
season_description = _label("", episode_section, 22)
|
||||
episode_list = VBoxContainer.new()
|
||||
@@ -46,6 +60,9 @@ func _ready() -> void:
|
||||
func load_seasons() -> void:
|
||||
generation += 1
|
||||
var current := generation
|
||||
if series_item.is_empty(): series_item = {"Id": series_id, "Type": "Series"}
|
||||
all_seasons_button.hide()
|
||||
_set_watched_control(series_item, _series_watched_changed.bind(current), "Applies to every episode in every season of this series.")
|
||||
_clear(season_grid)
|
||||
_label("Loading seasons...", season_grid, 22)
|
||||
var result := await api.seasons(series_id)
|
||||
@@ -56,6 +73,15 @@ func load_seasons() -> void:
|
||||
Widgets.button("Retry seasons", load_seasons, season_grid)
|
||||
return
|
||||
var seasons: Array = result.data.get("Items", [])
|
||||
var all_watched := not seasons.is_empty()
|
||||
for season in seasons:
|
||||
var user: Dictionary = season.get("UserData", {}) if season.get("UserData") else {}
|
||||
all_watched = all_watched and bool(user.get("Played", false))
|
||||
var series_user: Dictionary = series_item.get("UserData", {}) if series_item.get("UserData") else {}
|
||||
series_user["Played"] = all_watched
|
||||
series_item["UserData"] = series_user
|
||||
watched_control._refresh()
|
||||
watched_control.button.disabled = seasons.is_empty()
|
||||
if seasons.is_empty():
|
||||
_label("No seasons available.", season_grid, 22)
|
||||
for season in seasons:
|
||||
@@ -71,6 +97,8 @@ func open_season(season: Dictionary) -> void:
|
||||
var current := generation
|
||||
season_section.hide()
|
||||
episode_section.show()
|
||||
all_seasons_button.show()
|
||||
_set_watched_control(selected_season, _season_watched_changed.bind(current), "Applies to every episode in this season.")
|
||||
all_seasons_button.grab_focus()
|
||||
episode_heading.text = str(season.get("Name", "Season")) + " episodes"
|
||||
season_description.text = str(season.get("Overview") if season.get("Overview") else "")
|
||||
@@ -102,18 +130,57 @@ func open_season(season: Dictionary) -> void:
|
||||
if card.item.get("ParentIndexNumber") == null:
|
||||
card.item["ParentIndexNumber"] = season.get("IndexNumber")
|
||||
card.artwork = artwork
|
||||
card.api = api
|
||||
card.chosen.connect(func(chosen: Dictionary): play_requested.emit(chosen))
|
||||
card.version_requested.connect(func(chosen: Dictionary): version_requested.emit(chosen))
|
||||
episode_list.add_child(card)
|
||||
card.watched_control.changed.connect(_refresh_season_watched)
|
||||
_refresh_season_watched()
|
||||
section_changed.emit()
|
||||
|
||||
func _refresh_season_watched() -> void:
|
||||
if watched_control.busy: return
|
||||
var all_watched := true
|
||||
var count := 0
|
||||
for card in episode_list.get_children():
|
||||
if not card is EpisodeCard: continue
|
||||
count += 1
|
||||
var user: Dictionary = card.item.get("UserData", {}) if card.item.get("UserData") else {}
|
||||
all_watched = all_watched and bool(user.get("Played", false))
|
||||
selected_season["UserData"] = {"Played": all_watched and count > 0}
|
||||
watched_control._refresh()
|
||||
watched_control.button.disabled = count == 0
|
||||
|
||||
func _set_watched_control(item: Dictionary, action: Callable, tooltip: String) -> void:
|
||||
navigation.show()
|
||||
_clear(watched_host)
|
||||
watched_control = preload("res://scripts/ui/watched_control.gd").new()
|
||||
watched_control.api = api
|
||||
watched_control.item = item
|
||||
watched_control.changed.connect(action)
|
||||
watched_host.add_child(watched_control)
|
||||
watched_control.button.disabled = true
|
||||
watched_control.button.tooltip_text = tooltip
|
||||
|
||||
func _series_watched_changed(current: int) -> void:
|
||||
if not _current(current): return
|
||||
await load_seasons()
|
||||
if _current(current + 1): watched_control.button.grab_focus()
|
||||
|
||||
func _season_watched_changed(current: int) -> void:
|
||||
if not _current(current): return
|
||||
await open_season(selected_season)
|
||||
if _current(current + 1) and not selected_season.is_empty():
|
||||
watched_control.button.grab_focus()
|
||||
|
||||
func _show_seasons() -> void:
|
||||
selected_season = {}
|
||||
generation += 1
|
||||
episode_section.hide()
|
||||
navigation.hide()
|
||||
season_section.show()
|
||||
section_changed.emit()
|
||||
if season_grid.get_child_count() == 0: load_seasons()
|
||||
load_seasons()
|
||||
if season_grid.get_child_count() > 0:
|
||||
var first := season_grid.get_child(0) as Control
|
||||
if first and first.focus_mode != Control.FOCUS_NONE: first.grab_focus()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
extends VBoxContainer
|
||||
|
||||
signal changed
|
||||
|
||||
var api: JellyfinClient
|
||||
var item: Dictionary
|
||||
var button: Button
|
||||
var status: Label
|
||||
var busy := false
|
||||
|
||||
func _ready() -> void:
|
||||
button = Widgets.button("", _toggle, self)
|
||||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
status = Widgets.label("", self, 18)
|
||||
status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
status.custom_minimum_size.x = 200
|
||||
status.hide()
|
||||
_refresh()
|
||||
|
||||
func _refresh() -> void:
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
button.text = "Mark as unwatched" if user.get("Played", false) else "Mark as watched"
|
||||
button.accessibility_name = button.text + " " + DetailsMetadata.title(item)
|
||||
|
||||
func _toggle() -> void:
|
||||
if busy: return
|
||||
busy = true
|
||||
button.disabled = true
|
||||
status.text = "Saving..."
|
||||
status.show()
|
||||
var user: Dictionary = item.get("UserData", {}) if item.get("UserData") else {}
|
||||
var result := await api.set_watched(str(item.Id), not user.get("Played", false))
|
||||
if not is_inside_tree(): return
|
||||
busy = false
|
||||
button.disabled = false
|
||||
if not result.ok:
|
||||
status.text = result.error
|
||||
return
|
||||
if not result.data is Dictionary or not result.data.get("Played") is bool:
|
||||
status.text = "Could not confirm watched status. Reopen the item to refresh it."
|
||||
return
|
||||
var updated := user.duplicate(true)
|
||||
updated.merge(result.data, true)
|
||||
item["UserData"] = updated
|
||||
status.hide()
|
||||
_refresh()
|
||||
changed.emit()
|
||||
@@ -26,7 +26,7 @@ static func input(placeholder: String, parent: Node, secret: bool = false) -> Li
|
||||
parent.add_child(result)
|
||||
return result
|
||||
|
||||
static func watched_badge(image_frame: PanelContainer) -> void:
|
||||
static func watched_badge(image_frame: PanelContainer) -> Control:
|
||||
var overlay := Control.new()
|
||||
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
image_frame.add_child(overlay)
|
||||
@@ -41,6 +41,7 @@ static func watched_badge(image_frame: PanelContainer) -> void:
|
||||
badge.offset_top = 8
|
||||
badge.offset_right = -8
|
||||
badge.offset_bottom = 36
|
||||
return overlay
|
||||
|
||||
static func theme() -> Theme:
|
||||
var result := Theme.new()
|
||||
|
||||
@@ -64,6 +64,13 @@ func run_checks() -> void:
|
||||
await api.episodes("show id", "season & id", 100)
|
||||
check("SeasonId=season%20%26%20id" in last_request and "StartIndex=100" in last_request, "Episode pagination stays inside the selected season")
|
||||
check("Fields=Overview%2CUserData%2CMediaSources" in last_request and "IsMissing=false" in last_request, "Episode cards request versions and summaries and exclude missing episodes")
|
||||
payload = '{"Played":true,"PlaybackPositionTicks":0}'
|
||||
code = 200
|
||||
result = await api.set_watched("movie id", true)
|
||||
check(result.ok and result.data.Played and last_request.begins_with("POST /jellyfin/Users/fixture-user/PlayedItems/movie%20id "), "Mark watched sends an authenticated POST for the selected item")
|
||||
payload = '{"Played":false,"PlaybackPositionTicks":0}'
|
||||
result = await api.set_watched("episode id", false)
|
||||
check(result.ok and not result.data.Played and last_request.begins_with("DELETE /jellyfin/Users/fixture-user/PlayedItems/episode%20id "), "Mark unwatched sends DELETE and returns updated user data")
|
||||
code = 401
|
||||
payload = ""
|
||||
result = await api.request("/Users/Me")
|
||||
|
||||
@@ -6,11 +6,28 @@ class FixtureApi extends JellyfinClient:
|
||||
var fail_episodes := false
|
||||
var empty_seasons := false
|
||||
var empty_episodes := false
|
||||
var watched_states: Dictionary = {}
|
||||
var watched_calls: Array = []
|
||||
var fail_watched := false
|
||||
var hold_watched := false
|
||||
signal release_watched
|
||||
|
||||
func set_watched(id: String, watched: bool) -> Dictionary:
|
||||
watched_calls.append([id, watched])
|
||||
if hold_watched: await release_watched
|
||||
await get_tree().process_frame
|
||||
if fail_watched: return {"ok": false, "error": "Offline"}
|
||||
watched_states[id] = watched
|
||||
if id == "agatha":
|
||||
for season in season_items: watched_states[season.Id] = watched
|
||||
return {"ok": true, "data": {"Played": watched}}
|
||||
var season_items := [{"Id": "one", "Name": "Season 1", "IndexNumber": 1}, {"Id": "two", "Name": "Season 2", "IndexNumber": 2}]
|
||||
|
||||
func seasons(_series_id: String) -> Dictionary:
|
||||
await get_tree().process_frame
|
||||
if fail_seasons: return {"ok": false, "error": "Offline"}
|
||||
for season in season_items:
|
||||
season["UserData"] = {"Played": watched_states.get(season.Id, false)}
|
||||
return {"ok": true, "data": {"Items": [] if empty_seasons else season_items}}
|
||||
|
||||
func episodes(series_id: String, season_id: String, offset: int = 0) -> Dictionary:
|
||||
@@ -24,6 +41,9 @@ class FixtureApi extends JellyfinClient:
|
||||
items.append({"Id": season_id + str(index), "Name": ["Seekest Thou the Road", "Circle Sewn with Fate", "Through the trials", "The final chapter"][index], "IndexNumber": index + 1, "RunTimeTicks": 24000000000, "CommunityRating": 7.0, "Overview": "Agatha and her companions face another trial on the Witches' Road.", "UserData": {"PlaybackPositionTicks": 3000000000 if index == 0 else 0, "Played": index == 1}})
|
||||
if offset == 0 and not items.is_empty():
|
||||
items[0]["MediaSources"] = [{"Id": "first"}, {"Id": "second"}]
|
||||
if watched_states.has(season_id):
|
||||
for item in items:
|
||||
item.UserData = {"Played": watched_states[season_id], "PlaybackPositionTicks": 0}
|
||||
return {"ok": true, "data": {"Items": items, "TotalRecordCount": 0 if empty_episodes else 4}}
|
||||
|
||||
var failures := 0
|
||||
@@ -87,7 +107,34 @@ func run_checks() -> void:
|
||||
await settle()
|
||||
check(first.size.x <= view.scroll.size.x, "Episode cards must fit the available panel width.")
|
||||
check(first.title_label.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "Episode text must remain inside the panel.")
|
||||
check(is_equal_approx(view.back_button.global_position.y, browser.all_seasons_button.global_position.y) and is_equal_approx(view.back_button.global_position.y, browser.watched_control.button.global_position.y), "All three season navigation buttons share one row.")
|
||||
check(browser.watched_control.get_global_rect().end.x <= view.get_global_rect().end.x, "The season watched action fits the right edge.")
|
||||
root.size.x = 1536
|
||||
check(browser.watched_control.button.text == "Mark as watched", "Partly watched seasons offer Mark as watched.")
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(api.watched_calls == [["one", true]], "The season action sends the season ID rather than an episode or series ID.")
|
||||
check(browser.watched_control.button.text == "Mark as unwatched", "Watched seasons offer Mark as unwatched.")
|
||||
for card in browser.episode_list.get_children():
|
||||
check(card.watched_badge.visible and card.play_button.text == "Play episode", "All episode pages refresh their badges and progress after a season change.")
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
for card in browser.episode_list.get_children():
|
||||
check(not card.watched_badge.visible, "Marking a season unwatched refreshes every episode.")
|
||||
api.fail_watched = true
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(browser.watched_control.status.visible and not browser.watched_control.button.disabled, "A failed season change shows an error and permits retry.")
|
||||
check(not browser.episode_list.get_child(0).watched_badge.visible, "Failed season changes preserve episode state.")
|
||||
api.fail_watched = false
|
||||
api.hold_watched = true
|
||||
browser.watched_control.button.pressed.emit()
|
||||
browser.open_season(api.season_items[1])
|
||||
await settle()
|
||||
api.release_watched.emit()
|
||||
await settle()
|
||||
check(browser.selected_season.Id == "two" and browser.episode_list.get_child(0).item.Id == "two0", "A late season update cannot replace another season's view.")
|
||||
api.hold_watched = false
|
||||
browser.open_season(api.season_items[0])
|
||||
browser.all_seasons_button.pressed.emit()
|
||||
browser.open_season(api.season_items[1])
|
||||
@@ -115,6 +162,41 @@ func run_checks() -> void:
|
||||
browser.season_grid.get_child(1).pressed.emit()
|
||||
await settle()
|
||||
check(browser.season_grid.get_child_count() == 2, "Retry must restore season selection.")
|
||||
api.empty_episodes = false
|
||||
check(browser.watched_control.item.Id == "agatha" and not browser.all_seasons_button.visible, "The overview offers a series action without All seasons.")
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(api.watched_calls.back() == ["agatha", true], "The overview submits the series ID for a whole-series update.")
|
||||
check(browser.watched_control.button.text == "Mark as unwatched", "A watched series offers Mark as unwatched.")
|
||||
for card in browser.season_grid.get_children():
|
||||
check(card.item.UserData.Played, "The series action refreshes every season.")
|
||||
await capture("res://dist/series-watched-preview.png")
|
||||
browser.open_season(api.season_items[1])
|
||||
await settle()
|
||||
for card in browser.episode_list.get_children():
|
||||
check(card.watched_badge.visible, "Episodes in another season reflect the whole-series update.")
|
||||
browser.all_seasons_button.pressed.emit()
|
||||
await settle()
|
||||
api.fail_watched = true
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(browser.watched_control.status.visible and browser.watched_control.button.text == "Mark as unwatched", "A failed series change preserves state and shows an error.")
|
||||
api.fail_watched = false
|
||||
browser.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(api.watched_calls.back() == ["agatha", false] and browser.watched_control.button.text == "Mark as watched", "A whole series can be marked unwatched.")
|
||||
for card in browser.season_grid.get_children():
|
||||
check(not card.item.UserData.Played, "All seasons refresh to unwatched.")
|
||||
api.hold_watched = true
|
||||
browser.watched_control.button.pressed.emit()
|
||||
browser.open_season(api.season_items[0])
|
||||
await settle()
|
||||
api.release_watched.emit()
|
||||
await settle()
|
||||
check(browser.selected_season.Id == "one" and browser.watched_control.item.Id == "one", "A late series response cannot replace the season action.")
|
||||
api.hold_watched = false
|
||||
browser.all_seasons_button.pressed.emit()
|
||||
await settle()
|
||||
api.empty_seasons = true
|
||||
browser.load_seasons()
|
||||
await settle()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
extends SceneTree
|
||||
|
||||
class FixtureApi extends JellyfinClient:
|
||||
var calls: Array = []
|
||||
var fail := false
|
||||
var malformed := false
|
||||
|
||||
func set_watched(id: String, watched: bool) -> Dictionary:
|
||||
calls.append([id, watched])
|
||||
await get_tree().process_frame
|
||||
if fail: return {"ok": false, "error": "Cannot reach the server."}
|
||||
return {"ok": true, "data": {} if malformed else {"Played": watched, "PlaybackPositionTicks": 0, "PlayCount": 1 if watched else 0}}
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_checks")
|
||||
|
||||
func check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func settle() -> void:
|
||||
for frame in range(5): await process_frame
|
||||
|
||||
func run_checks() -> void:
|
||||
var api := FixtureApi.new()
|
||||
root.add_child(api)
|
||||
var view := DetailsView.new()
|
||||
view.api = api
|
||||
view.item = {"Id": "movie", "Type": "Movie", "Name": "Example", "UserData": {"Played": false, "PlaybackPositionTicks": 7200000000, "IsFavorite": true}}
|
||||
root.add_child(view)
|
||||
await settle()
|
||||
var control = view.watched_control
|
||||
check(control.button.text == "Mark as watched", "Unwatched movies offer Mark as watched")
|
||||
control.button.pressed.emit()
|
||||
control.button.pressed.emit()
|
||||
check(control.button.disabled and api.calls.size() == 1, "Saving prevents duplicate requests")
|
||||
check(not view.item.UserData.Played, "Watched status waits for server confirmation")
|
||||
await settle()
|
||||
check(view.item.UserData.Played and view.item.UserData.IsFavorite, "Confirmed user data preserves unrelated fields")
|
||||
check(control.button.text == "Mark as unwatched" and view.play_button.text == "Play movie", "Movie buttons reflect the confirmed watched state and cleared resume position")
|
||||
check(not view.resume_label.visible and not view.play_from_start_button.visible, "Cleared progress hides stale resume controls")
|
||||
control.button.pressed.emit()
|
||||
await settle()
|
||||
check(not view.item.UserData.Played and api.calls.back() == ["movie", false], "Movies can be marked unwatched again")
|
||||
api.fail = true
|
||||
control.button.pressed.emit()
|
||||
await settle()
|
||||
check(not view.item.UserData.Played and not control.button.disabled and control.status.visible, "Failed requests preserve state and allow retry")
|
||||
api.fail = false
|
||||
api.malformed = true
|
||||
control.button.pressed.emit()
|
||||
await settle()
|
||||
check(not view.item.UserData.Played and control.status.visible, "Incomplete responses do not claim a saved change")
|
||||
api.malformed = false
|
||||
var episode := EpisodeCard.new()
|
||||
episode.api = api
|
||||
episode.artwork = view.artwork
|
||||
episode.item = {"Id": "episode", "Type": "Episode", "Name": "Episode", "UserData": {"Played": true, "PlaybackPositionTicks": 7200000000}}
|
||||
root.add_child(episode)
|
||||
await settle()
|
||||
check(episode.watched_badge.visible, "Watched episodes show a badge")
|
||||
episode.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(not episode.watched_badge.visible and episode.play_button.text == "Play episode", "Marking an episode unwatched updates its badge and playback action")
|
||||
check(not "Continue from" in episode.facts_label.text and not "watched" in episode.play_button.accessibility_name, "Episode progress and accessibility labels are refreshed")
|
||||
episode.watched_control.button.pressed.emit()
|
||||
await settle()
|
||||
check(episode.watched_badge.visible and api.calls.back() == ["episode", true], "Episodes can be marked watched again")
|
||||
view.queue_free()
|
||||
episode.queue_free()
|
||||
api.queue_free()
|
||||
await process_frame
|
||||
print("Watched status checks: ", "PASS" if failures == 0 else str(failures) + " failures")
|
||||
quit(1 if failures else 0)
|
||||
Reference in new issue
Block a user