MVVM architecture and MMP's event notification mechanism (two‐way Observer Pattern) ‐ for interested Delphi developers - BazzaCuda/MinimalistMediaPlayerX GitHub Wiki
The entire MMP project was completely rewritten back in June/July 2024 to achieve these aims:
- rearchitect the entire source code to show that MVVM is achievable in, and suitable for, Delphi projects.
- prove that 'loose coupling' is achievable in Delphi projects by drastically reducing the number of interdependent units listed in
usesclauses - prove that 'loose coupling' is achievable in Delphi projects by drastically reducing the interdependence of object classes on each other.
- implement Dependency Injection where possible and other SOLID principles where practicable.
- program to interfaces not implementations.
- achieve complete separation of the UI from the program logic, such that an entirely different UI could be implemented and hooked-up to the underlying "business logic" - something that the Delphi IDE [since v1] has never encouraged and has never facilitated, in my opinion. (see my non-existent book, "The Pitfalls of RAD" :D)
- accomplish all this in two weeks, not two months.
To a large degree, all of the above has been achieved; certainly as a proof of concept, the MMP rewrite achieved its goals.
Even more could have been achieved had this approach been adopted right from the start of the project, rather than rewriting an already released product.
There are a couple of places where I pragmatically cut corners simply from a desire not to break working code. view.mmpFormTimeLine.pas and mmpProgramUpdates.pas are two such examples. This is predominantly because I never got around to refactoring those units as I should have; again, so as not to break somewhat involved, working code. They both started life as "proof of concept"s and ended up staying that way once I got them working.
As Stephen Fry said on the first episode of Saturday Night Fry, "I'm obliged to tell you that some of you may find parts of this program rather badly written and incompetently performed; I hope not, but we shall see". With that in mind, I always welcome constructive feedback and discussion.
Before you start your next Delphi project, you may wish to read these brief notes and study the source code to see if your project could benefit from implementing this architecture from the start. Certainly, all my major projects from now on will be written this way.

Firstly, I recommend reading this short 142-page book by John Kouraklis. Most of this book can be skim-read (much of it is very straight-forward Delphi code) and is therefore very quick to work through and to digest what its teaching.
John takes a typical two-form Delphi project and adapts it to MVVM with lots of source code explanations in each chapter. This book greatly helps in being able to adapt how we usually view a typical Delphi project and how that differs from a Delphi MVVM application.
He also introduces an object-to-object notification system which MMP significantly expands on to make it far more comprehensive and powerful, and to make two-way communication possible (One object can issue a directive or a request for information knowing nothing about which object will service that directive or request; the receiving object can return the information without knowing which object originated the request). This in itself reduces the interdependency between different object classes (and Delphi units), as you will see.
All of this allowed me to then introduce Dependency Injection, starting with the .dpr project file which creates, for example, the media player, progress bar and captions forms, which are then passed into the ViewModel, rather than the ViewModel object class creating them in its constructor, as might normally be the case.
MMP implements an MVVM architecture: Model - View - ViewModel.
Because Delphi projects are largely governed by the TForms we create (especially MainForm), I prefer to think of it as View - ViewModel - Model.

In this V-VM-M architecture:
-
View: encapsulates every form that is displayed to the user. It doesn't necessarily include MessageBox error messages or confirmations, but it could have done. In the project, all form units are prefixed withview.Objects in theVieware only permitted to display information to the user (as directed by theViewModel) and to pass the results of user-interactions to theViewModel. TheViewis not permitted to directly communicate with objects in theModel. It is not even permitted to know that theModelexists. All it knows about is how to display information to the user, how to interact with the user, and how to communicate those interactions to theViewModel. -
ViewModel: the application's engine. The bulk of the code will be in theViewModel. It is responsible for maintaining the application's "state" and that of theViewand theModel. It is responsible for being the conduit between theViewand theModel, in both directions of dataflow. In the project, all units of theViewModelare prefixed withviewModel. -
Model: the data layer. Responsible for retrieving data from files and databases etc. as instructed by theViewModelor triggered by external data changes. Often this data retrieval will be at the behest of user-instructions received by theViewModelfrom theView. InMMPof course, the audio, video and image data being supplied by MPV is exposed by theModel, and provided to theViewvia theViewModel. TheModelitself knows nothing about theViewor theViewModel. All it knows is how to retrieve data from external sources and how to satisfy requests for data. In the project, all units of theModelare prefixed withmodel.
The other mmp... .pas units contain general purpose functions grouped by category; the T...Class.pas units contain classes which support specific MMP functionality.
The original project relied heavily on the use of singletons. These have been greatly reduced. The config file singleton CF, and the global state singleton GS are two of the few survivors of the rewrite.
Typically, in a Delphi project, the main form (in fact, every TForm) creates its major visual and non-visual components and other internal objects during formCreate.
By contrast in MMP, the only component that the main form creates itself is the TApplicationEvents component.

There was only ever going to be one instance of this component in the project, so for expedient or pragmatic [as opposed to dogmatic] reasons, there were no good reasons for creating an instance of TApplicationEvents and injecting it into the main form.
All the work of building the major components of the user interface is carried out in the project (.dpr) file. These objects are then provided ("injected"), fully formed, to the current instantiation of TMMPUI's viewModel:
function initUI(const aMainForm: TForm): boolean;
var
vVideoPanel: TPanel;
begin
...
vVideoPanel := mmpThemeCreateVideoPanel(aMainForm);
...
MMPUI.viewModel := newViewModel;
MMPUI.viewModel.mediaPlayer := newMediaPlayer;
MMPUI.viewModel.initUI(aMainForm, vVideoPanel);
MC(aMainForm).initCaption(vVideoPanel, CF.asInteger[CONF_MAIN_CAPTION]); // Main Caption: single caption at the top of the window
ST(aMainForm).initCaptions(vVideoPanel, CF.asInteger[CONF_TIME_CAPTION]); // Sub-Titles: multiple captions at the bottom of the window
MMPUI.viewModel.progressBar := newProgressBar.initProgressBar(ST.captionsForm, CF.asInteger[CONF_PROGRESS_BAR], 0);
...
MMPUI.viewModel.playlist := newPlaylist;
...
end;Notice that even the initUI function doesn't know which form instance it's operating on. This is the essence of Dependency Injection:
- callers should provide to functions, procedures and object classes whatever it is they're being asked to operate on; they never create them themselves.
- methods are permitted to create minor temporary objects that they need to perform the required action on whatever was passed to them.
One major benefit of this approach is that TMediaPlayer is entirely generic, allowing multiple instances to be created and to operate entirely independently. So the TMediaPlayer used for images, audio and video in the main TMMPUI media window, is entirely independent of the TMediaPlayer used to display images in the Image & Thumbnail Browser. The same goes for TPlaylist, TProgressBar, etc.
From a purest point of view it would have been good to go the whole [Dependency Injection] hog and not call:
Application.CreateForm(TMMPUI, MMPUI);...in the .dpr file but, rather, to create the instance of TMMPUI manually and set it up as the application's foreground window.
However, that would then have brought Delphi's underlying TApplication mechanism to the fore and that just seemed like dogmatic overkill. Maybe I'll do that with a future project when I'm feeling particularly masochistic :D - as it stands, the project architecture has greatly benefited from the pragmatic Dependency Injection decisions taken.
A quick look at the code in view.mmpFormMain.pas will show that, unlike most Delphi projects, it contains no real code; it is simply a proxy by which user interactions are sent to the View Model. For example:
procedure TMMPUI.FormMouseWheelUp(Sender: TObject; Shift: TShiftState; MousePos: TPoint; var Handled: Boolean);
begin
case FViewModel = NIL of TRUE: EXIT; end;
FViewModel.onMouseWheelUp(shift, mousePos, handled);
end;One of the major pitfalls of RAD (Rapid Application Design) is that it encourages the developer to write code directly in the event handler procedures. Right from version 1 of Delphi, I was as guilty of this as anyone. After all, that's what all the code examples in the books were telling us to do! Even now, I will often still do this in small, throw-away projects. But for more substantial projects I follow four self-imposed rules:
- When a user interacts with a
TForm, prefer having a separate object class to which those interactions are delegated. - If it's probably only going to be a one-unit project and Rule 1 seems like overkill, at least put all the logic in functions and procedures that the event handlers have to call.
- The only code allowed in an event handler is for modifying the UI in response to user interactions and to call other methods which do the actual work.
- When it all gets a bit messy, remember that you really should have followed Rule 1 instead.
An example of allowable code in an event handler of a small project might be that when the user alters the text in a TEdit, the TEdit's onChange event handler might enable a save TButton. An event handler certainly shouldn't be doing anything more substantial than that.
This separates the User Interface from the "business logic" right from the start which is an essential GUI design principle. And if it turns out that the project does grow into a multi-unit one, you already have a good foundation for entirely removing all the business logic from the form unit.
MMP implements what could be described as a two-way variation of the Observer Pattern, and this can be summarized as follows:
- Class objects issue one or more event notices when some underlying condition (or data) changes.
- Multiple other objects can subscribe to receive those event notices and react to them as appropriate.
- Objects can also issue requests (e.g. for particular data) in the form of notices, to which other objects respond.
- Neither the objects issuing the notices, nor the receiving objects, nor the request-responding objects, have any knowledge of each other's implementations.
- In the case of request notices, the responding class has no knowledge of the class that made the request; it simply services the request.
- In the case of a receiving class, it has no knowledge of the class that issued the notice; it simply responds to it.
- Finally, in the case of a request-issuing class, it has no knowledge of the class that services the request, only that it can provide the required information. In
MMP, the majority of request events are application-wide, so the requesting object has no clue which object responded with the required data.
There are two types of notice:
- Application-wide notices which subscribed objects filter in order to respond only to those they're interested in.
- Object-specific notices which an object can subscribe to, to receive information direct from a particular class object.
The way objects react to certain event notices is very reminiscent of the Windows messaging system, e.g. WM_MOUSEMOVE and the way Delphi programs can choose to respond to such a message or ignore it. In fact, the Windows messaging system was a direct influence on the writing of MMP's event notices. However, the major difference is that MMP's event notification mechanism is synchronous rather than asynchronous (think SendMessage rather than PostMessage); consequently, after issuing a notification, the next line of code can be confident that, all other things being equal, an object should have responded to the notification in the same way that any other method "reacts" when you call it to carry out an action or provide data.
It's probably easier to give some specific examples at this point.
The event notice mechanism is implemented in mmpNotify.notices.pas, mmpNotify.notifier.pas and mmpNotify.subscriber.pas
TTickTimer in mmpTickTimer.pas issues an evTickTimer event notice every second for the entire time that MMP is running. This is a technique borrowed from the gaming community to which I have made some very modest contributions (see Caesar III Augustus on GitHub). For performance reasons, objects must subscribe directly to ITickTimer in order to receive these tick events. TTickTimer doesn't issue evTickTimer events as application-wide notices; they're only sent to the list of subscribers.
So, each second TTickTimer issues, in effect, the following:
INotifier.notifySubscribers(newNotice(evTickTimer));
TMediaPlayer in model.mmpMediaPlayer.pas is one such subscriber:
constructor TMediaPlayer.create;
begin
TT.notifier.subscribe(newSubscriber(onTickTimer)); // subscribe to TTickTimer's event notices
appNotifier.subscribe(newSubscriber(onNotify)); // subscribe to receive all application-wide event notices
end;Both of these statements create a new ISubscriber instance and specify a callback method ("function of object") within TMediaPlayer to which TTickTimer's INotifier and the application-wide INotifier will send the event notices. (The application-wide INotifier is created on the first call to "notifyApp()" in the .dpr project file)
.subscribe() simply adds the new ISubscriber to each INotifier's list of subscribers.
When TTickTimer calls
INotifier.notifySubscribers(newNotice(evTickTimer));...INotifier simply runs through its TList<ISubscriber>
for subscriber in subscribers do subscriber.notifySubscriber(aNotice);...which, in turn, calls each ISubscriber's callback method passing the event notice as a parameter to the method, just like any Delphi event handler procedure:
function TSubscriber.notifySubscriber(const aNotice: INotice): INotice;
begin
result := aNotice;
case assigned(FNotifyMethod) of TRUE: FNotifyMethod(aNotice); end;
end;Event notices fall into two categories: basic event alerts and those that carry a payload - a data cargo.
evTickTimer is of the former category: it simply notifies subscribers that the event occurred.
TMediaPlayer responds to this event notice in its onTickTimer method:
function TMediaPlayer.onTickTimer(const aNotice: INotice): INotice;
begin
result := aNotice;
case aNotice = NIL of TRUE: EXIT; end;
...
case FMediaType of mtAudio, mtVideo: FNotifier.notifySubscribers(newNotice(evMPPosition, mpvPosition(mpv))); end;
...
end;TMediaPlayer.onTickTimer responds to the evTickTimer event by creating a new event notice, evMPPosition which in addition to the event notice constant, can also carry an integer. In this case, it notifies all TMediaPlayer subscribers of the current playback position (in seconds) of the audio or video file being played. Thanks to TTickTimer, this happens every second that such a media file is being played - every subscriber that needs to know when the audio/video file changes playback position gets notified.
One subscriber to TMediaPlayer's INotifier is TProgressBar in view.mmpProgressBar.pas which adjusts the position of the progress bar based on the integer received in the event notice:
function TProgressBar.onNotify(const aNotice: INotice): INotice;
begin
result := aNotice;
case aNotice = NIL of TRUE: EXIT; end;
...
case
...
evMPPosition: progressBar.position := aNotice.integer;
...
end;Because TProgressBar is subscribed to the application-wide INotifier, it has no idea where this event notice came from, nor does it know anything about TMediaPlayer, nor about audio or video files. It simply responds to the event notice by updating the progress bar position. It will have previously received an evMPDuration event notice to set the maximum for the progress bar.
As well as an integer, an INotice can also currently carry
- a string
- a boolean
- a TComponent reference
- a TPoint
- a TMediaType
- a TMessage (winApi.messages.pas)
- a TShiftState
- even an entire record (see
TWndRecin mmpConsts.pas)
You can of course extend these depending on your requirements.
A natural extension of the event notice payload is that if an object class can receive and use an item of data present in the notice, it can also respond to the event notice by populating one of the fields, e.g. the integer, and the calling class will receive that data. The INotice instance will exist for as long as it's "in scope" in the code block that issues the ...notify(newNotice(...)).
One common example of this is that objects can ask TPlaylist if it contains any items:
...
var vHasItems := notifyApp(newNotice(evPLReqHasItems)).tf; // e.g. from viewModel.mmpVM.pas
...This issues an application-wide evPLReqHasItems notice and stores the boolean TRUE/FALSE result that comes back. It has no clue where from.
As it happens, it's TPlaylist that responds because TPlaylist is subscribed to the application-wide INotifier, and evPLReqHasItems is one of the notices that its onNotify method checks for:
function TPlaylist.onNotify(const aNotice: INotice): INotice;
begin
result := aNotice;
case aNotice = NIL of TRUE: EXIT; end;
...
case aNotice.event of
...
evPLReqHasItems: aNotice.tf := SELF.hasItems; // set the boolean field (.tf) in the received notice
...
end;
end;TPlaylist.onNotify returns the INotice it received, now with its boolean payload, back to the caller.
The event notification mechanism is built entirely on interfaces. In theory they should all free their resources when they finally go "out of scope" when the application closes and all the object classes that were subscribers and/or notifiers are destroyed.
Nevertheless, it is good practice to explicitly unsubscribe from all notifiers so there is no chance that some may respond and keep interfaced resources alive during the shutdown process.
Viz, the view model (TVM) in viewModel.mmpVM.pas:
constructor TVM.create;
begin
inherited;
FSubscriber := appNotifier.subscribe(newSubscriber(onNotify));
FSubscriberTT := TT.notifier.subscribe(newSubscriber(onTickTimer));
end;
destructor TVM.Destroy;
begin
TT.notifier.unsubscribe(FSubscriberTT);
appNotifier.unsubscribe(FSubscriber);
inherited;
end;In fact, unsubscribing during shutdown is the only reason for remembering the subscriber references (FSubscriber and FSubscriberTT in the above example). During the operation of the event notification mechanism they serve no purpose, having been recorded by each INotifier to which it subscribed.
Having said that, during INotifier's Destroy method, it forcibly unsubscribes all [remaining] subscribers anyway, so the need for an ISubscriber to manually unsubscribe may be purely down to addressing edge cases. For example, TMediaPlayer subscribes both to ITickTimer's notifier and to the application-wide notifier, but leaves them to unsubscribe TMediaPlayer as a "listener" during their respective shutdowns.
constructor TMediaPlayer.create;
begin
TT.notifier.subscribe(newSubscriber(onTickTimer));
appNotifier.subscribe(newSubscriber(onNotify));
end;
destructor TMediaPlayer.Destroy;
begin
case mpv = NIL of FALSE: mpv.free; end;
inherited;
end;
destructor TNotifier.Destroy;
begin
for var vSubscriber in FSubscribers do unsubscribe(vSubscriber);
FSubscribers.free;
inherited;
end;My advice, as a general principle, would be to always store the ISubscriber instances created by each call to newSubscriber and to deliberately unsubscribe in .Destroy() or sooner. This prevents any subscribed object from trying to respond to a notification while it's in the process of being freed, and for it to then still try to call any of its code when the SELF reference is no longer valid, as this will likely cause elusive access violations.
Now that we fully understand our two-way Observer Pattern, it's time to slightly modify the interfaces in keeping with common OOP practices, common applications of the Observer Pattern, and a possibly unwritten rule about interfaces.
Instead of calling
TT.notifier.subscribe(...we really should be calling
TT.subscribe(...In the case of ITickTimer, the quickest way to achieve this is by simply defining ITickTimer as implementing INotifier, and then implementing INotifier's three methods in TTickTimer:
type
ITickTimer = interface(INotifier)
['{520972A9-0FDF-4557-A9A2-BE8B3D5B143A}']
end;
TTickTimer = class(TInterfacedObject, ITickTimer)
...
public
function subscribe(const aSubscriber: ISubscriber): ISubscriber;
procedure unsubscribe(const aSubscriber: ISubscriber);
procedure notifySubscribers(const aNotice: INotice);
end;
...
function TTickTimer.subscribe(const aSubscriber: ISubscriber): ISubscriber;
begin
result := FNotifier.subscribe(aSubscriber);
end;
procedure TTickTimer.unsubscribe(const aSubscriber: ISubscriber);
begin
FNotifier.unsubscribe(aSubscriber);
end;So far, so good: calls to TT.subscribe and TT.unsubscribe are now simply proxies for the original calls to FNotifier.subscribe and FNotifier.unsubscribe, and we no longer have to expose TTickTimer's internal FNotifier member to consumers.
However, we have a problem. Because ITickTimer now inherits from INotifier it must also implement notifySubscribers which would immediately allow consumers to call TT.notifySubscribers. But TTickTimer should be the only class that notifies its subscribers - it alone knows the conditions which should trigger that notification. We could get around this by having TTickTimer's implementation of notifySubscribers do nothing:
procedure TTickTimer.notifySubscribers(const aNotice: INotice);
begin
// ignore any attempt to call this from outside TTickTimer
end;This definitely works but consumers of ITickTimer shouldn't be given the impression in the first place that there might be a legitimate reason why they would ever want to call notifySubscribers. This is the [possibly] unwritten rule about interfaces that I mentioned earlier: don't expose methods and properties via an interface that the consumer shouldn't use.
Fortunately, the solution is both easy and elegant. We split the INotifier interface into two interfaces, with the first interface only having the subscribe and unsubscribe methods, and the second only having the notifySubscribers method:
ISubscribable = interface
['{6E55B835-3C16-404E-AAA0-8C4354BBEB59}']
function subscribe(const aSubscriber: ISubscriber): ISubscriber;
procedure unsubscribe(const aSubscriber: ISubscriber);
end;
INotifier = interface(ISubscribable)
['{DD326AE1-5049-43AA-9215-DF53DB5FC958}']
procedure notifySubscribers(const aNotice: INotice);
end;Notice that because INotifier now inherits from ISubscribable, any class that implements INotifier must still implement all three methods, just as they did before, which means their current code is completely unaffected by this change.
Now, instead of ITickTimer inheriting from INotifier, it can inherit from ISubscribable, and TTickTimer now only has two methods that it must implement:
type
ITickTimer = interface(ISubscribable)
['{520972A9-0FDF-4557-A9A2-BE8B3D5B143A}']
end;
...
TTickTimer = class(TInterfacedObject, ITickTimer)
public
...
// ISubscribable
function subscribe(const aSubscriber: ISubscriber): ISubscriber;
procedure unsubscribe(const aSubscriber: ISubscriber);
end;An added bonus is that by implementing ISubscribable, ITickTimer indicates to any potential consumers that it can be subscribed to.
Loose coupling, both between units and object classes. To be continued.
It's fast. Also, to be continued.
function TSomeClass.onNotify(const aNotice: INotice): INotice;
begin
result := aNotice;
case aNotice = NIL of TRUE: EXIT; end;
...
end;Amongst other reasons, this allows the caller to immediately check whether the required payload member was set by the receiver:
...
case notifyApp(newNotice(evPLReqCurrentFolder)).text = '' of TRUE: EXIT; end;
...e.g. GS.notify(newNotice(evGSAutoCenter, TRUE));
TO BE CONTINUED
Thanks to Delphi's boolean case statement (case <boolean> of TRUE/FALSE: ...), I stopped using IF statements in all my code some time ago. I regard the resultant code as clearer and more elegant, especially when it eliminates nested IFs which have always looked a mess and have always been a potential maintenance nightmare, in my opinion. Case statements also seem much easier to understand and to extend; at least to me they do. That's not to say I get it right every time - there are some nested case statements in MMP which I would like to rewrite. I just feel that their if...begin end...else if begin end...else begin...end equivalents would be significantly worse, and much of the available Pascal code is testament to that. I also loathe the ubiquitous begin...end construct in Pascal. It's ugly. The way I lay out my case statements allows me to mitigate some of the inherent inelegance of the syntax. More on that in a moment.
I am currently playing with functional programming (see mmpFuncProg.pas) and seeing how many of the case statements can now be replaced with purely declarative statements. I haven't yet found a declarative/functional programming solution to the "if then EXIT" guard clause paradigm, other than not calling a method in the first place if the condition isn't met in advance. Not sure how practical, or elegant such an approach would be. I like elegant-looking code. It's fun though to experiment with, and challenge, programming "norms".
A final word of warning: On my large, curved, widescreen monitor, I use a Delphi code editor which is 200-300 characters wide (about a third to a half of the entire width of the monitor with a 10pt font), and I use the full width of that editor to arrange my code into blocks which are visually distinct from other blocks, so as to emphasize their logical and semantic purpose. This makes my code much clearer and easier to come back to at a later date. Pascal with its ubiquitous begin...end blocks is naturally clunky. Embarcadero take this clunkiness and make it even worse, in my opinion, with their coding style - in fact, I find their code downright ugly. By contrast, I try to reduce the inherent clunkiness of Pascal. Having done some programming both in C# and C and other C-like languages such as Javascript, I will admit to have "curly-brace envy" :D - hence why you will often find all my "end"s on the same line. In these days of 3,440+ pixel-wide monitors, why people still stick to a narrow statement width which is a hangover from punched cards, Fortran coding sheets and 80-character-line text-only monitors, I will never understand. Even the Delphi IDE still automatically wraps event procedures at about 70 characters! Maybe it's just me. But at least you've now been warned :P
(c) 2025, Baz Cuda