Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions osu.Game.Tests/Database/ReplayBookmarkDatabaseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Scoring;

namespace osu.Game.Tests.Database
{
[TestFixture]
public class ReplayBookmarkDatabaseTests : RealmTest
{
[Test]
public void TestBookmarksPersist()
{
RunTestWithRealmAsync(async (realm, _) =>
{
Guid id = Guid.NewGuid();

await realm.WriteAsync(r =>
{
var ruleset = CreateRuleset();
r.Add(ruleset);
r.Add(new ScoreInfo(ruleset: ruleset, beatmap: new BeatmapInfo(ruleset: ruleset)) { ID = id });
});

await realm.WriteAsync(r =>
{
var si = r.Find<ScoreInfo>(id)!;
si.ReplayBookmarks.Add(1000);
si.ReplayBookmarks.Add(5000);
si.ReplayBookmarks.Add(12000);
});

realm.Run(r => r.Refresh());

int[] bookmarks = realm.Run(r => r.Find<ScoreInfo>(id)!.ReplayBookmarks.ToArray());
Assert.That(bookmarks, Is.EqualTo(new[] { 1000, 5000, 12000 }));
});
}

[Test]
public void TestBookmarksClearedAndReplaced()
{
RunTestWithRealmAsync(async (realm, _) =>
{
Guid id = Guid.NewGuid();

await realm.WriteAsync(r =>
{
var ruleset = CreateRuleset();
r.Add(ruleset);
r.Add(new ScoreInfo(ruleset: ruleset, beatmap: new BeatmapInfo(ruleset: ruleset)) { ID = id });
});

await realm.WriteAsync(r =>
{
var si = r.Find<ScoreInfo>(id)!;
si.ReplayBookmarks.Add(1000);
si.ReplayBookmarks.Add(2000);
});

await realm.WriteAsync(r =>
{
var si = r.Find<ScoreInfo>(id)!;
si.ReplayBookmarks.Clear();
si.ReplayBookmarks.Add(9000);
});

realm.Run(r => r.Refresh());

int[] bookmarks = realm.Run(r => r.Find<ScoreInfo>(id)!.ReplayBookmarks.ToArray());
Assert.That(bookmarks, Is.EqualTo(new[] { 9000 }));
});
}

[Test]
public void TestNoBookmarksReturnsEmpty()
{
RunTestWithRealmAsync(async (realm, _) =>
{
Guid id = Guid.NewGuid();

await realm.WriteAsync(r =>
{
var ruleset = CreateRuleset();
r.Add(ruleset);
r.Add(new ScoreInfo(ruleset: ruleset, beatmap: new BeatmapInfo(ruleset: ruleset)) { ID = id });
});

realm.Run(r => r.Refresh());

int[] bookmarks = realm.Run(r => r.Find<ScoreInfo>(id)!.ReplayBookmarks.ToArray());
Assert.That(bookmarks, Is.Empty);
});
}
}
}
126 changes: 126 additions & 0 deletions osu.Game.Tests/Visual/Gameplay/TestSceneReplayBookmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Play;

namespace osu.Game.Tests.Visual.Gameplay
{
public partial class TestSceneReplayBookmark : RateAdjustedBeatmapTestScene
{
private TestReplayPlayer player = null!;

private ReplayBookmarkController bookmarkController
=> player.ChildrenOfType<ReplayBookmarkController>().Single();

private void loadPlayer()
{
AddStep("load replay player", () =>
{
var ruleset = new OsuRuleset();
Beatmap.Value = CreateWorkingBeatmap(ruleset.RulesetInfo);
SelectedMods.Value = new[] { ruleset.GetAutoplayMod() };
player = new TestReplayPlayer(false);
LoadScreen(player);
});

AddUntilStep("wait for player loaded", () => player.IsLoaded);
}

[Test]
public void TestAddBookmark()
{
loadPlayer();

AddStep("seek to 5000", () => player.Seek(5000));
AddStep("add bookmark", () => bookmarkController.AddBookmarkAtCurrentTime());

AddAssert("one bookmark added", () => bookmarkController.Bookmarks.Count, () => Is.EqualTo(1));
AddAssert("bookmark near 5000", () => bookmarkController.Bookmarks[0], () => Is.EqualTo(5000).Within(200));
}

[Test]
public void TestNoDuplicateBookmark()
{
loadPlayer();

AddStep("seek to 5000", () => player.Seek(5000));
AddStep("add bookmark twice", () =>
{
bookmarkController.AddBookmarkAtCurrentTime();
bookmarkController.AddBookmarkAtCurrentTime();
});

AddAssert("only one bookmark", () => bookmarkController.Bookmarks.Count, () => Is.EqualTo(1));
}

[Test]
public void TestRemoveClosestBookmark()
{
loadPlayer();

AddStep("add bookmark at 5000", () => bookmarkController.Bookmarks.Add(5000));
AddStep("seek near bookmark", () => player.Seek(4500));
AddStep("remove closest", () => bookmarkController.RemoveClosestBookmark());

AddAssert("no bookmarks remain", () => bookmarkController.Bookmarks.Count, () => Is.EqualTo(0));
}

[Test]
public void TestRemoveDoesNothingWhenFar()
{
loadPlayer();

AddStep("add bookmark at 5000", () => bookmarkController.Bookmarks.Add(5000));
AddStep("seek far from bookmark", () => player.Seek(10000));
AddStep("remove closest", () => bookmarkController.RemoveClosestBookmark());

AddAssert("bookmark still present", () => bookmarkController.Bookmarks.Count, () => Is.EqualTo(1));
}

[Test]
public void TestSeekToNextBookmark()
{
loadPlayer();

AddStep("add bookmarks", () =>
{
bookmarkController.Bookmarks.Add(3000);
bookmarkController.Bookmarks.Add(7000);
});
AddStep("seek to start", () => player.Seek(0));
AddStep("seek to next bookmark", () => bookmarkController.SeekBookmark(1));

AddUntilStep("clock near first bookmark", () => player.GameplayClockContainer.CurrentTime, () => Is.EqualTo(3000).Within(500));
}

[Test]
public void TestSeekToPreviousBookmark()
{
loadPlayer();

AddStep("add bookmarks", () =>
{
bookmarkController.Bookmarks.Add(3000);
bookmarkController.Bookmarks.Add(7000);
});
AddStep("seek past second bookmark", () => player.Seek(9000));
AddStep("seek to previous bookmark", () => bookmarkController.SeekBookmark(-1));

AddUntilStep("clock near second bookmark", () => player.GameplayClockContainer.CurrentTime, () => Is.EqualTo(7000).Within(500));
}

[Test]
public void TestSeekWithNoBookmarksDoesNotThrow()
{
loadPlayer();

AddAssert("no bookmarks", () => bookmarkController.Bookmarks.Count, () => Is.EqualTo(0));
AddStep("seek forward (no-op)", () => bookmarkController.SeekBookmark(1));
AddStep("seek backward (no-op)", () => bookmarkController.SeekBookmark(-1));
}
}
}
3 changes: 2 additions & 1 deletion osu.Game/Database/RealmAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ public class RealmAccess : IDisposable
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
/// 51 2025-07-22 Add ScoreInfo.Pauses.
/// 52 2026-05-22 Add ScoreInfo.ReplayBookmarks
/// </summary>
private const int schema_version = 51;
private const int schema_version = 52;

/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
Expand Down
18 changes: 17 additions & 1 deletion osu.Game/Input/Bindings/GlobalActionContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ public static IEnumerable<GlobalAction> GetGlobalActionsFor(GlobalActionCategory
new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward),
new KeyBinding(InputKey.Comma, GlobalAction.StepReplayBackward),
new KeyBinding(InputKey.Period, GlobalAction.StepReplayForward),
new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.ReplayAddBookmark),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.B }, GlobalAction.ReplayRemoveClosestBookmark),
new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.ReplaySeekToPreviousBookmark),
new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.ReplaySeekToNextBookmark),
new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.ToggleReplaySettings),
};

Expand Down Expand Up @@ -535,7 +539,19 @@ public enum GlobalAction
EditorSubmitBeatmap,

[LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.EditExternally))]
EditorEditExternally
EditorEditExternally,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ReplayAddBookmark))]
ReplayAddBookmark,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ReplayRemoveClosestBookmark))]
ReplayRemoveClosestBookmark,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ReplaySeekToPreviousBookmark))]
ReplaySeekToPreviousBookmark,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ReplaySeekToNextBookmark))]
ReplaySeekToNextBookmark
}

public enum GlobalActionCategory
Expand Down
20 changes: 20 additions & 0 deletions osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,26 @@ public static class GlobalActionKeyBindingStrings
/// </summary>
public static LocalisableString StepReplayBackward => new TranslatableString(getKey(@"step_replay_backward"), @"Step replay backward one frame");

/// <summary>
/// "Add bookmark to timeline"
/// </summary>
public static LocalisableString ReplayAddBookmark => new TranslatableString(getKey(@"add_replay_bookmark"), @"Add bookmark to timeline");

/// <summary>
/// "Remove closest bookmark from timeline"
/// </summary>
public static LocalisableString ReplayRemoveClosestBookmark => new TranslatableString(getKey(@"remove_replay_closest_bookmark"), @"Remove closest bookmark from timeline");

/// <summary>
/// "Seek to previous bookmark"
/// </summary>
public static LocalisableString ReplaySeekToPreviousBookmark => new TranslatableString(getKey(@"seek_replay_to_previous_bookmark"), @"Seek to previous bookmark");

/// <summary>
/// "Seek to next bookmark"
/// </summary>
public static LocalisableString ReplaySeekToNextBookmark => new TranslatableString(getKey(@"seek_replay_to_next_bookmark"), @"Seek to next bookmark");

/// <summary>
/// "Toggle chat focus"
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public class LegacyReplaySoloScoreInfo
[JsonProperty("pauses")]
public int[] Pauses { get; set; } = [];

[JsonProperty("replay_bookmarks")]
public int[] ReplayBookmarks { get; set; } = [];

public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo
{
OnlineID = score.OnlineID,
Expand All @@ -63,6 +66,7 @@ public class LegacyReplaySoloScoreInfo
UserID = score.User.OnlineID,
TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null,
Pauses = score.Pauses.ToArray(),
ReplayBookmarks = score.ReplayBookmarks.ToArray(),
};
}
}
1 change: 1 addition & 0 deletions osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public Score Parse(Stream stream)
PopulateTotalScoreWithoutMods(score.ScoreInfo);

score.ScoreInfo.Pauses.AddRange(readScore.Pauses);
score.ScoreInfo.ReplayBookmarks.AddRange(readScore.ReplayBookmarks);
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions osu.Game/Scoring/ScoreInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ public class ScoreInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftD

public IList<int> Pauses { get; } = null!;

public IList<int> ReplayBookmarks { get; } = null!;

public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null)
{
Ruleset = ruleset ?? new RulesetInfo();
Expand Down
17 changes: 17 additions & 0 deletions osu.Game/Screens/Play/HUD/ArgonSongProgress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,33 @@ private void updateGraphVisibility()
graph.FadeTo(ShowGraph.Value ? 1 : 0, 200, Easing.In);
}

private ReplayBookmarkOverlayBar? bookmarkOverlay;

protected override void Update()
{
base.Update();
content.Height = bar.Height + bar_height + info.Height;
graphContainer.Height = bar.Height;
if (bookmarkOverlay != null)
bookmarkOverlay.Height = bar.Height;
}

protected override void UpdateProgress(double progress, bool isIntro)
{
bar.Progress = isIntro ? 0 : progress;
}

protected override Drawable CreateBookmarkOverlay()
{
return bookmarkOverlay = new ReplayBookmarkOverlayBar
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
RelativeSizeAxes = Axes.X,
Height = bar_height,
StartTime = FirstHitTime,
EndTime = LastHitTime,
};
}
}
}
Loading
Loading