I’ve just completed a task that I set out to do about five years ago, and I am pretty proud (and tired)! I have just finished scanning in every single photo that I’ve acquired over my 40+ years of life, fixing up their dates to reflect the date when the picture was taken, not when it was scanned. Furthermore, I have tagged every photo on the disk drive (scanned or originally digital) with metadata saying who was in the photo and where it was taken. All of my family videos have been scanned in as well, and all of that work (plus all of my music tracks and personal documents) is now backed up weekly onto a 1.5TB disk that I store in a fireproof safe. I’ve now got over 16,000 photos and 20+ hours of video on the system, going all the way back to the year 1875 (a picture of my great-great-grandparents on their wedding day), all of which are (hopefully) now preserved against tragic circumstances.
Of course, having done all that work, I wanted to relax and view the photos! I didn’t want to buy one of those picture frames that can do slide shows, since they are expensive and I’d have to find one that would support a big enough (and also expensive) memory card – it’s 22GB worth of pictures.
My first choice was to use the screensaver that comes with Windows. I run both Windows Vista and Windows XP at home (and Windows7 at work!), and the photo screensaver that comes with them is pretty decent in both cases. But, after a while, I got tired of my kids asking “Who is that? When was that? Where was that?” over and over again. If only I had a setting on the screensaver that would show the “DateTaken” property, and the Tags associated with the photo…
Well, of course, this was a good opportunity for me to learn about screensavers, which I hadn’t really ever thought much about before. And thus began the journey…
What is a screensaver?
A screensaver is just an application renamed to have a .SCR extension, and placed in the Windows system directory. That’s it.
Now, of course, the application should be a little special. It needs to take up the whole screen, it shouldn’t have borders, menus, captions, or any other decorations, and it needs to turn itself off when the mouse moves. Furthermore, it needs to expose its options in a certain way, so that the user can set them in the screensaver control panel.
I found out that MSDN has an example of a screensaver built using VS2003-level functionality, but targeting .NET 2.0. You can find it here, and I’ll be referring back to it throughout this blog, much of which won’t make sense unless you’ve read through that code (consider it as homework J). That screensaver draws random shapes on the screen and gives you a few options to change the speed and quality of the shapes. However, it doesn’t leverage any of the new coolness we’ve introduced in recent versions of Visual Basic, so I figured that it was time for a screensaver makeover. I copied it down to my machine and opened it up. It has three classes in it – an Options class, an Options form, and a Screensaver form. We’ll discuss these in order.
The options class
The options class defines settings that the user wants to persist between runs, and also provides a mechanism for strong them. The original settings were:
Private m_Speed As String = “Fast”
Private m_Shape As String = “Ellipse”
Private m_IsTransparent As Boolean = True
I changed these to:
#Region “Member variables”
Private m_Speed As Integer = 6
Private m_ShowTags As Boolean = True
Private m_ShowDates As Boolean = True
Private m_Directory As String = “”
Private m_UseSubdirectories As Boolean = True
Basically, I’m going to let the used specify the time (in seconds) that the picture should stay up, whether or not to show the tags and the date superimposed on the photo, where to look for photos, and whether or not to search subdirectories of that directory. I also went ahead & generated get/set properties for each of those, ripping out the old ones.
Now, the rest of this file spends a lot of its code in opening up an options file, reading the data, writing to it, etc. Fortunately, we don’t need any of that code anymore. To set up automatic setting storage, I simply right-clicked the project, chose “Properties,” navigated to the Settings tab, and entered in my five settings as given above. My entire persistence code then became:
Public Sub LoadOptions()
Me.Speed = My.Settings.Speed
Me.ShowDate = My.Settings.ShowDate
Me.ShowTags = My.Settings.ShowTags
Me.Directory = My.Settings.Directory
Me.UseSubdirectories = My.Settings.UseSubdirectories
If String.IsNullOrEmpty(Directory) Then ‘ Default to “My Pictures” if not initialized
Me.Directory = My.Computer.FileSystem.SpecialDirectories.MyPictures
Public Sub SaveOptions()
My.Settings.Speed = Me.Speed
My.Settings.ShowDate = Me.ShowDate
My.Settings.ShowTags = Me.ShowTags
If Not String.IsNullOrEmpty(Directory) Then ‘ Don’t bother saving
My.Settings.Directory = Me.Directory
My.Settings.UseSubdirectories = Me.UseSubdirectories
My.Settings.Save() ‘ Necessary since we’ll be forcing application exits later
eliminating about 40 lines of code devoted to serializing out the class.
The options form
Of course, by that point, I was getting build errors all over the place, since I was now out of synch with the options form and the screensaver form. Let’s take a look at the options form. For clarity’s sake, I wanted to hide the form-initialization code that VS2003 used to shove into the class file. I created a new file in the project, named it “frmOptions.Designer.vb”, and typed the following into it:
Partial Class frmOptions
I then copied all of the generated crud into that partial class and got it out of the way.
All I really needed to do now is to replace the existing controls with three checkboxes (tags,date, and subdirectories) and two edit boxes (speed and directory). That’s easy enough to do. However, not content with doing it the easy way, I decided to limit the “speed” text box so that it can only accept numbers. There’s a VB snippet that does exactly that for combo boxes (“Windows Forms Applications”-> Forms->Restrict a Control’s Acceptable Keystrokes”), and I’ve modified it for my edit box:
Const WM_KEYDOWN As Integer = &H100
Protected Overrides Function ProcessCmdKey _
(ByRef msg As Message, _
ByVal keyData As Keys) As Boolean
If msg.Msg = WM_KEYDOWN Then
Return Not ((keyData >= Keys.D0 And keyData <= Keys.D9) _
Or keyData = Keys.Back Or keyData = Keys.Left _
Or keyData = Keys.Right Or keyData = Keys.Up _
Or keyData = Keys.Down Or keyData = Keys.Delete)
Return MyBase.ProcessCmdKey(msg, keyData)
Then, given that, I reopened the frmOptions.Designer.vb file that I just created (can’t find it? Make sure that “Show All Files” is turned on in the Solution Explorer’s tool bar) and changed TextBox to restrictedTextBoxClass in two locations:
Friend WithEvents txtSpeed As frmOptions.restrictedTextBoxClass
Me.txtSpeed = New ScreenSaver.frmOptions.restrictedTextBoxClass
I had to explicitly *rebuild* at this point – otherwise, the designer wouldn’t have known how to render the control if I were I to switch to the designer.
The screensaver form
Here’s where the real work happened. Using the error messages in the Error List, I ripped out the lines of code that referred to things that didn’t exist anymore (like graphics, eliipses, etc). I also moved the initialization crud into a partial class file, like I did for the options dialog.
The form used doesn’t actually start out with any controls on it. I set its BackColor to black, and then I added two controls (and the order was important):
(1) A PictureBox control which I sized to be the same size as the form. I set its BackColor to be black, and set its SizeMode property to Zoom. (I will not be doing any cutesy transition effects – I dislike those intensely, so I’ll leave that as an exercise for the reader.)
(2) A label control with the BackColor set to black and the ForeColor set to White. My default text for this control was “Loading Pictures…”
There’s nothing else I have to do to the form itself – it’s already set up to fill the monitor (WindowState=Maximized), decorations are turned off (ControlBox = False, MinimizeBox and MaximizeBox are False, ShowInTaskbar = False, SizeGripStyle=Hide), and TopMost is equal to True. Note that you will want to temporarily change TopMost to be False when developing & debugging; otherwise, you won’t be able to see the debugger, unless you are running dual-monitor!
I knew that I was going to need to use some .NET 3.0 functionality in order to dig out the picture metadata, so I right-clicked the project and chose “Properties.” On the “Compile” tab, I clicked the “Advanced Compiler Options” button and, on the bottom of the resulting window, I changed the Target Framework to be the 3.5 version (3.0 would have worked also, but I like being current). I then used the “Add Reference” menu item off of the project to add some needed references to the project – PresentationCore (found by navigating to C:\Program Files\Reference Assemblies\Microsoft\Framework\v3.0\PresentationCore.dll on the Browse tab) and WindowsBase (found on the .NET tab).
Now, it was all coding. My member variable had been whittled down to this:
Private m_Options As New Options()
Private m_Random As New Random()
Private m_IsActive As Boolean = False
Private m_MouseLocation As Point
Private m_files As ReadOnlyCollection(Of String)
Private m_fileCount As Integer = 0
Those would allow me to track the user options, generate random numbers, determine if the mouse was active and where it was at, hold onto the list of picture files that I wanted to display, and keeping a count of the latter as well.
I created a helper function to position my label. Basically, I always want it exactly at the bottom of the screen, centered horizontally:
Private Sub SetLabelLocation()
Me.lblDescription.Top = Me.Size.Height – Me.lblDescription.Height
Me.lblDescription.Left = (Me.Size.Width – Me.lblDescription.Width) \ 2
Now I could rewrite the form’s Load event handler (comments in all of this code omitted for brevity, though you’ll see them in the final product):
Private Sub frmSceenSaver_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
Me.tmrUpdateScreen.Interval = m_Options.Speed * 1000
Me.tmrUpdateScreen.Enabled = True
If m_Options.UseSubdirectories = True Then
m_files = My.Computer.FileSystem.GetFiles(m_Options.Directory, FileIO.SearchOption.SearchAllSubDirectories, “*.jpg”)
m_files = My.Computer.FileSystem.GetFiles(m_Options.Directory, FileIO.SearchOption.SearchTopLevelOnly, “*.jpg”)
If m_files IsNot Nothing Then
m_fileCount = m_files.Count
Me.lblDescription.Text = “No pictures found!”
This is pretty straightforward code: I load the options from the user settings, move the label to the correct position, translate to desired seconds of duration to milliseconds, and enable the Timer (which was already attached to the form in the original code). Then, I do a search for all JPG files in the desired directory (and in subdirectories, if the user chose that option) and collect them in the m_files object. If I got any files back, then I keep track of the count to use later on when generating a random number. Otherwise, I tell the user that I couldn’t find any pictures. (The string should really go into the resources, but that makes the blog posts less readable.)
As with the original version that I downloaded, I wanted the application to end when the user moves or clicks the mouse. The problem, however, is that the label and picture are hiding the form, so that the form never gets mouse events. This was easily remedied by adding the appropriate events to the event handlers (I’ve underlined those below), and otherwise this code is unchanged:
Private Sub frmScreenSaver_MouseMove(ByVal sender As Object, _
ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseMove, _
Private Sub frmScreenSaver_MouseDown(ByVal sender As Object, _
ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseDown, _
Periodically, the timer will fire, and that calls the Tick handler, wherein all I did was change the name of the helper function from DrawShape to DrawPicture (if any files exist to be drawn):
Private Sub tmrUpdateScreen_Tick(ByVal sender As System.Object, ByVal e As _
System.EventArgs) Handles tmrUpdateScreen.Tick
If m_fileCount <> 0 Then
As I mentioned above, DrawShape became DrawPicture, and I ripped out all of the code from it and entered the strange and inscrutable world of image metadata. Metadata such as “DakeTaken” and “Keywords” aren’t available from the normal FileInfo; you have to dig for them, and it’s not an intuitive process. I started out by getting a random number from 0 to m_fileCount – 1, and then determining if I need to grab metadata from the resulting file:
Private Sub DrawPicture()
Dim index As Integer = Me.m_Random.Next(0, m_fileCount – 1)
If m_Options.ShowDate = True OrElse m_Options.ShowTags = True Then
“Index” is the index of the picture I’ll be showing. Now, I needed to crack that file open:
Dim jpegStream As New System.IO.FileStream(m_files(index), _
FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)
Dim jpegDecoder As New JpegBitmapDecoder(jpegStream, _
Dim jpegFrame As BitmapFrame = jpegDecoder.Frames(0)
Dim jpegInplace As InPlaceBitmapMetadataWriter = _
The first line should look pretty familiar – I’m just opening up a file like normal. In the second line, I’m attaching the resulting stream to a JpegBitmapDecoder, which allows me to treat the file as if it is a JPEG (which it is), so that I don’t need to know or care about the JPEG format when parsing through it. The third line gets me the first frame of that JPEG (and yes, 99.9999% of the time, there’s only one frame), and in the fourth line, I get a pointer to the metadata of that frame.
(BTW: Yes, the object is called a “metadata writer.” There is no such thing as a “metadata reader” – you use this object for both. Yes, that’s silly. My screensaver just does reading, of course; if you’re interested in how to actually *write* metadata into the file once you’ve gotten this far, I’ve coded up an example in the completed code, attached at the bottom of this post.)
The next bits retrieve metadata from the writer and combine them into a display string:
Dim sDescription As String = “”
If m_Options.ShowDate = True Then
If Not String.IsNullOrEmpty(dt) Then
Dim ddt As Date = CDate(“#” & dt & “#”)
sDescription = ddt.Date & “: “
“DateTaken” actually returns the time as well, and I just wanted the date, so I converted the DateTaken string to a true date and then retrieved the Date portion from it.
If m_Options.ShowTags = True Then
If jpegInplace.Keywords IsNot Nothing AndAlso jpegInplace.Keywords.Count > 0 Then
For Each keyword As String In jpegInplace.Keywords
If Not keyword.StartsWith(” “) Then
sDescription += keyword & “, “
Tags are referred to as “Keywords” in metadata, and they are stored in a read-only collection which I have to iterate through. I ignore tags that start with a space character – this is because I personally mask out incorrect tags with spaces rather than removing them. This is because it’s important to keep the overall size of the metadata constant when modifying the file in-place, and I (for whatever reason) frequently write in the wrong tag. (The proper way to remove bad tags is to create a copy of the file which lacks them, but that’s heavy-weight.)
After that, I do a bit of cosmetic work on the string:
If sDescription.EndsWith(“, “) OrElse sDescription.EndsWith(“: “) Then
sDescription = Microsoft.VisualBasic.Left(sDescription, sDescription.Length – 2)
As you can see, I’ve been concatentating the strings with “: “ or “; “, and since I’m too lazy to use fancy logic in the loop to look ahead to make sure I don’t add an extra one, I just trim any that I don’t need when all done.
Then I set the text of the label to be the description I’ve just created, center the label, and close the file:
Me.lblDescription.Text = sDescription
It was important for me to remember to close the file, since I’m about to tell the picture control to use that same file, and they can’t use it at the same time. But first, before loading up the picture, my code hides the label if the user didn’t want to show any text at all:
Now all that remains is to show the picture:
And that’s it; the old shape screensaver has been successfully transformed into a new picture screensaver that also shows the viewer the date the picture was taken, who is in the picture, and where they were. The last thing to do, after building and debugging the app, is to copy it up to the c:\windows\system32 direcotry and change its extension to be SCR instead of EXE.
Calling the screensaver from Windows
Note that I didn’t need to change the “Main” function, but I’ll briefly describe it here anyway. First, we note that this is a single-threaded application which can handle arguments passed to it:
<STAThread()> Shared Sub Main(ByVal args As String())
If args.Length > 0 Then
Windows will pass one of three flags as arguments. The first possibility is “p”, which means “show a preview of the screensaver.” As with the original version of this code, I didn’t do any special here, but just exited, since previews are beyond the scope of this blog and frankly, if you need a preview of a slideshow, then you’re not understanding the screensaver altogether:
If args(0).ToLower = “/p” Then
The second option is “c”, which means that Windows wants to display the option settings. Once the user dismisses the options, I close down the application:
If args(0).ToLower.Trim().Substring(0, 2) = “/c” Then
Dim userOptionsForm As New frmOptions()
The third option (and also the default option, if no arguments are supplied) is “s”, which means that I should show the screensaver itself, and then close it down once it’s exited:
If args(0).ToLower = “/s” Then
Dim screenSaverForm As New ScreenSaver.frmScreenSaver()
Dim screenSaverForm As New ScreenSaver.frmScreenSaver()
And there you have it. The completed code (along with another method which describes setting metadata) is attached to this blog post, and will also be posted on my Temple of VB site.
‘Til next time,
[Edit: I re-posted the ZIP file and modified the blog so that the SizeMode for the Picture was correctly set to Zoom instead of Center Image, and I now force a save of the options when changes are applied so that Application.Exit doesn’t skip that step.]