diff --git a/TODO.md b/TODO.md index d389f7d..b4a3de5 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/scripts/jellyfin/client.gd b/scripts/jellyfin/client.gd index c8a4ae6..8724170 100644 --- a/scripts/jellyfin/client.gd +++ b/scripts/jellyfin/client.gd @@ -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"})) diff --git a/scripts/ui/details.gd b/scripts/ui/details.gd index 45242da..139b0e4 100644 --- a/scripts/ui/details.gd +++ b/scripts/ui/details.gd @@ -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() diff --git a/scripts/ui/episode_card.gd b/scripts/ui/episode_card.gd index 0293f8b..ff8ef72 100644 --- a/scripts/ui/episode_card.gd +++ b/scripts/ui/episode_card.gd @@ -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) diff --git a/scripts/ui/series_browser.gd b/scripts/ui/series_browser.gd index dacfed3..7ab0528 100644 --- a/scripts/ui/series_browser.gd +++ b/scripts/ui/series_browser.gd @@ -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() diff --git a/scripts/ui/watched_control.gd b/scripts/ui/watched_control.gd new file mode 100644 index 0000000..f232f7c --- /dev/null +++ b/scripts/ui/watched_control.gd @@ -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() diff --git a/scripts/ui/widgets.gd b/scripts/ui/widgets.gd index 3fd6eac..f1ba847 100644 --- a/scripts/ui/widgets.gd +++ b/scripts/ui/widgets.gd @@ -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() diff --git a/tests/test_http.gd b/tests/test_http.gd index 228b1f5..61313c8 100644 --- a/tests/test_http.gd +++ b/tests/test_http.gd @@ -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") diff --git a/tests/test_series.gd b/tests/test_series.gd index e37b01b..13456b8 100644 --- a/tests/test_series.gd +++ b/tests/test_series.gd @@ -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() diff --git a/tests/test_watched.gd b/tests/test_watched.gd new file mode 100644 index 0000000..7f09d88 --- /dev/null +++ b/tests/test_watched.gd @@ -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)