Music: standalone video levels#52454
Conversation
| @@game_standalone_video ||= find_by_name("StandaloneVideo") | ||
| end | ||
|
|
||
| def self.standalone_video2 |
There was a problem hiding this comment.
can we think of a better name than standalone_video2?
There was a problem hiding this comment.
I tried for a while, and am open to ideas. I thought about video (but then the model collides with an existing Video class in the dashboard), VideoLab (since it has characteristics of a lab, but it isn't really a lab), and VideoLevel (but then naming of related files gets weird) for starters. This really will look a lot like a standalone_video level, but it will be React-based with a proper JS app backing it, and with support for level changes without page reloads.
There was a problem hiding this comment.
After seeing how much server-side support we have built for StandaloneVideo, including levelbuilder support, and our discussion about the disadvantages of adding another model and game type, I'm trying a different approach: we'll use StandaloneVideo on the server, but when we see it in use alongside Music Lab levels, we'll use a newer client-side implementation (name to be determined) which is React-based and supports changing levels without reloads.
| <ErrorBoundary fallback={<ErrorFallbackPage />} onError={onError}> | ||
| <div id="lab-container" className={moduleStyles.labContainer}> | ||
| {children} | ||
| <ProjectContainer channelId={getStandaloneProjectId()}> |
There was a problem hiding this comment.
I think this file will get way too big and confusing if we have to put every lab's configuration in here. At least for now it would be good to get each lab down to 1 line (so we would extract out MusicView wrapped by ProjectContainer into some other component), and have each one look like the line for StandaloneVideo2
There was a problem hiding this comment.
+1. It might be nice to store some map of appName -> React class separately, so we can just look it up there and render the desired component here
There was a problem hiding this comment.
Agreed on both points. For starters, it's lab-agnostic again.
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| visibility: currentApp !== 'music' && 'hidden', |
There was a problem hiding this comment.
why are we using visibility here rather than conditionally rendering?
There was a problem hiding this comment.
I tried using conditional rendering but then ran into a problem I also encountered in earlier explorations: we don't seem to have a clean way to tear down Blockly and then recreate it. If we stop rendering the lab, and then start again, we'll attempt to recreate Blockly, but hit a bunch of errors because it has left things behind on the window.
| // without reloading the whole page. | ||
| export function canChangeLevelInPage(currentLevelApp, newLevelApp) { | ||
| return currentLevelApp === 'music' && newLevelApp === 'music'; | ||
| const compatibleApps = ['music', 'standalone_video2']; |
There was a problem hiding this comment.
this logic feels like it is opening us up to allowing switches between any app type. Do we want that? It might be fine, it just feels like a departure from our previous thoughts.
There was a problem hiding this comment.
I expect a more likely situation is that we "family up" the sets that we can switch between, and had always kept that in the back of my mind. We still restrict these switches to those within the same lesson, so I think it makes sense that we can switch between more than one app type, but only a known subset within that given lesson.
| if (prevProps.currentLevelIndex !== this.props.currentLevelIndex) { | ||
| if ( | ||
| prevProps.currentLevelIndex !== this.props.currentLevelIndex && | ||
| this.props.isActive |
There was a problem hiding this comment.
if we switch to conditional rendering do we need to worry about isActive?
There was a problem hiding this comment.
We didn't need it in the end.
|
|
||
| # StandaloneVideo2 uses level_data, which is actual JSON in each .level file. | ||
|
|
||
| class StandaloneVideo2 < Level |
There was a problem hiding this comment.
should StandaloneVideo2 extend from StandaloneVideo?
There was a problem hiding this comment.
I think the server-side model should be different between them, since StandaloneVideo2 would start to rely on a level_data JSON blob rather than the separate serialized attributes used in StandaloneVideo, and probably doesn't need to inherit all of the older model's methods.
There was a problem hiding this comment.
As mentioned elsewhere, this change no longer adds a new model on the server, and instead just adds a new client for standalone video levels that are shown in a LabContainer context.
|
|
||
| const MusicView = connect( | ||
| state => ({ | ||
| isActive: |
There was a problem hiding this comment.
If we do want to go this route, I think this prop should be passed in at the top level by LabContainer directly, or this calculation should get encapsulated in a selector somewhere
There was a problem hiding this comment.
Agreed. At least for now, it doesn't seem to be necessary.
| import {logError} from '@cdo/apps/music/utils/MusicMetrics'; | ||
| import {getStandaloneProjectId} from '@cdo/apps/labs/projects/utils'; | ||
|
|
||
| $(document).ready(function () { |
There was a problem hiding this comment.
Interesting, so seems like we will still need to create one of these entry points for any new lab, but can then switch to any other lab from there?
There was a problem hiding this comment.
Yes, that is the principle. It's still early on this, but I think we could use a new, common piece of code when either of these level types is encountered in a script level.
There was a problem hiding this comment.
That common piece of code is now in place.
| </p> | ||
| </HelpTip> | ||
| </label> | ||
| <label> |
There was a problem hiding this comment.
We probably don't want levelbuilders to have to know this, right? Maybe this should just be a level property?
There was a problem hiding this comment.
+1, discussed a bit offline but seems setting this on the level (or the game?) would be better. I'm wondering if we can even go so far as storing the list of lab-lab compatible app types on the backend (like some list of game&.lab_lab_enabled?), and then send that list down to the front end, so that level-switching code doesn't need to store its own list of compatible apps.
There was a problem hiding this comment.
This is now set on the level. For music levels, uses_lab_container? will always be true. For standalone_video levels, we can mark it true using a serialized attribute for the specific levels that appear alongside music levels. A bonus is that the level-switching code no longer stores its own list of compatible apps, and instead just checks whether the user is moving between two levels that use LabContainer.
| @@ -0,0 +1,60 @@ | |||
| import $ from 'jquery'; | |||
There was a problem hiding this comment.
I like having a lablab entrypoint 🎉
There was a problem hiding this comment.
+1! one thing that comes to mind is handling locale files - maybe the entrypoint just loads all the locale files for lab-lab compatible apps? don't know how large that would be though.
There was a problem hiding this comment.
For now that's exactly what is happening, in _lab2.html.haml.
| ); | ||
| }); | ||
|
|
||
| const LabLab = () => { |
There was a problem hiding this comment.
can we move this to another file since it would probably be nice to have it in a tsx file? It also would ensure we keep the entrypoint small
| @app_options = | ||
| if @level.is_a? Blockly | ||
| if @script_level&.lesson&.use_lab_container | ||
| {app: 'lablab'} |
There was a problem hiding this comment.
are we removing some app options by doing this? I believe the only one we are relying on is channel, but we want to make sure we are still sending it.
There was a problem hiding this comment.
Oh, do we still need channel ID rendered into the page?
There was a problem hiding this comment.
Yeah, we will for standalone projects
There was a problem hiding this comment.
Got it. I think I've now got that working.
| startSources: Source; | ||
| } | ||
|
|
||
| export interface LevelVideo { |
There was a problem hiding this comment.
Can this be part of levelData so we don't need to store it separately?
There was a problem hiding this comment.
I guess ideally this would be a variant type of level data. To that end, I've prepped a standalone PR (which builds on this one) which shows one idea for that. Would love thoughts: #52570
| </p> | ||
| </HelpTip> | ||
| </label> | ||
| <label> |
There was a problem hiding this comment.
+1, discussed a bit offline but seems setting this on the level (or the game?) would be better. I'm wondering if we can even go so far as storing the list of lab-lab compatible app types on the backend (like some list of game&.lab_lab_enabled?), and then send that list down to the front end, so that level-switching code doesn't need to store its own list of compatible apps.
| @@ -0,0 +1,60 @@ | |||
| import $ from 'jquery'; | |||
There was a problem hiding this comment.
+1! one thing that comes to mind is handling locale files - maybe the entrypoint just loads all the locale files for lab-lab compatible apps? don't know how large that would be though.
| }); | ||
|
|
||
| const LabLab = () => { | ||
| const currentApp = useSelector( |
There was a problem hiding this comment.
Can we get this from the backend directly? i feel a little wary of relying on progress to determine the app type - i'm not sure in what situation we'd be switching levels without progress, but feels like it would be better to decouple ourselves given that we're already retrieving other level data from the backend?
There was a problem hiding this comment.
It's a good point. The challenge is that we would like to have this information for all levels available up front so that we can start each switch instantly, and then the per-level level data is retrieved. So far, we've been using progressRedux as our source of truth for the set of levels, so it feels like a natural fit to also store the app type there. The bigger change would be to factor information for the available set of levels into a more general redux store.
| currentApp === 'music' ? getStandaloneProjectId() : null; | ||
|
|
||
| return ( | ||
| <ProjectContainer channelId={channelId || undefined}> |
There was a problem hiding this comment.
getStandaloneProjectId returns string | null but ProjectContainer wants string | undefined. Should we make these consistent?
There was a problem hiding this comment.
Yeah, string | undefined should be fine, especially after Molly's change here since we're no longer explicitly returning null.
There was a problem hiding this comment.
Great; I'll update the return value type of getStandaloneProjectId and tidy this up.
molly-moen
left a comment
There was a problem hiding this comment.
This is looking good! Couple questions/potential follow-ups
| logError({error: error.toString(), componentStack}) | ||
| } | ||
| > | ||
| <ProjectContainer channelId={channelId}> |
There was a problem hiding this comment.
should we rename ProjectContainer to be a little more generic? Can be done separately. Now it loads project and or level data, which is sort of a confusing thing to name.
There was a problem hiding this comment.
Agreed. I think best if that's done in a follow-up by someone who's more familiar with it!
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| visibility: currentAppName === 'music' ? 'visible' : 'hidden', |
There was a problem hiding this comment.
will we no longer need to do the visibility hiding after Sanchit's PR? #52588
There was a problem hiding this comment.
Quite possibly, yes. I'm still curious whether we might decide it's better just to adjust visibility in some cases to cut down on reloads of sounds in Music Lab.
| # the level, which usually include levelData. If this level is a | ||
| # StandaloneVideo then we put its properties into levelData. | ||
| def summarize_for_lab2_properties | ||
| video = specified_autoplay_video&.summarize(false)&.camelize_keys |
There was a problem hiding this comment.
would it be possible here to add a type property (ex. for Music is would be 'music', for Video it would be 'video'? This may make our lives easier on the frontend given we are using a union type, and will be adding to it going forward.
There was a problem hiding this comment.
I can add it here, though I'm not sure what to do with it on the front-end apart from double-checking that it matches expectations and maybe logging some kind of error if it isn't?
There was a problem hiding this comment.
It will help with type checking--if we know something has a music type we then will know which other fields it has. But it's not strictly necessary, it just could give us cleaner code down the line.
There was a problem hiding this comment.
Sure thing. As a note, I used the type property from Level, which comes through as "Music" or "StandaloneVideo".
sanchitmalhotra126
left a comment
There was a problem hiding this comment.
A few more smaller things but looking good!
| const currentAppName: string = currentLessonLevels.find( | ||
| (level: LevelWithProgress) => level.isCurrentLevel | ||
| ).app; | ||
| const currentLessonAppNames: string[] = Array.from( |
There was a problem hiding this comment.
Worth moving currentLessonAppNames into a selector in progressReduxSelectors, if it would be useful to reuse?
There was a problem hiding this comment.
I might leave this for a potential follow-up, since it feels a bit specific to this logic.
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| visibility: currentAppName === 'music' ? 'visible' : 'hidden', |
There was a problem hiding this comment.
This should be currentAppName === appProperty.name right?
| visibility: currentAppName === 'music' ? 'visible' : 'hidden', | ||
| }} | ||
| > | ||
| <ProgressContainer appType={appProperty.name}> |
There was a problem hiding this comment.
To help simplify this, I think we can move ProgressContainer up into set of common wrapper components. If the app doesn't use progress, it just won't call any functions on the progress manager and won't respond to progress manager state updates.
There was a problem hiding this comment.
Oh that's great, and helps simplify things.
| ); | ||
| } else if (appProperty.name === currentAppName) { | ||
| return ( | ||
| <div |
There was a problem hiding this comment.
Do we need to wrap this in a separate div? Can we just render the app view directly?
There was a problem hiding this comment.
I wanted a way to get a consistently-named div for any type of app, which has proven pretty useful for seeing things working in the DOM like this:
Screen.Recording.2023-07-05.at.1.25.39.PM.mov
| return ( | ||
| <Lab2Wrapper | ||
| onError={(error, componentStack) => | ||
| logError({error: error.toString(), componentStack}) |
There was a problem hiding this comment.
Just noting this for a later follow-up: logError is a music-specific logging function, so we should instead have more generic logging here (can be done as part of https://codedotorg.atlassian.net/browse/SL-918)
|
|
||
| $(document).ready(function () { | ||
| ReactDOM.render( | ||
| <Provider store={getStore()}> |
There was a problem hiding this comment.
Can we also replace this with <Lab2>? Or are we not even hitting this entrypoint anymore?
There was a problem hiding this comment.
We still hit this entrypoint when viewing an individual level using a /levels/[level_id] URL.
| @@ -0,0 +1,49 @@ | |||
| // StandaloneVideo | |||
There was a problem hiding this comment.
minor nit: left a similar comment on Molly's PR, but should we standardize where we put top-level docs for react functional components? i have a slight preference for putting them after imports, on top of the component declaration, but could go either way.
There was a problem hiding this comment.
I also don't have strong feelings, but I think there is one advantage to having them at the top of the file.
| targetWidth -= 80; | ||
| targetHeight -= 100 + childrenAreaHeight; |
There was a problem hiding this comment.
nit: constants and/or docs for some of these adjustment values?
| if @script_level && @level.uses_lab2? | ||
| {app: 'lab2', channel: view_options[:channel]} |
There was a problem hiding this comment.
Will we not use lab2 for non-script single levels (levels/xyz urls)?
There was a problem hiding this comment.
It's a good question. The Lab2 component right now has some assumptions that it's part of a ScriptLevel, since it examines the current lesson and has logic for loading multiple apps at once. Maybe it should be renamed to Lab2ScriptLevel or similar?
I'm definitely open to more comprehensive refactoring, but this change is also getting big enough that I wouldn't mind getting it merged, and then we can do more refactoring as follow-ups.
What do you think?
There was a problem hiding this comment.
Gotcha - yeah I think it would be nice to reduce/remove the dependency on progress redux so that Lab2 can be used for any lab on any page, but agreed that it can happen as a follow up. From what i'm seeing, I think if we were to pass the app type in as part of level properties, we shouldn't need to look up app type on the lesson, right? As far as loading multiple app views - it would be interesting to see how many apps need to stay rendered vs how many can be unmounted, but if it there aren't many that need to stay rendered, I think we can get away with handling multiple renders for all app types, rather than ones just in the lesson.
There was a problem hiding this comment.
From what i'm seeing, I think if we were to pass the app type in as part of level properties, we shouldn't need to look up app type on the lesson, right?
While we'd know the app type of the current level, we'd still want to know the app type of the destination level ahead of time, so that we can switch immediately to it and then initiate the retrieval of its own level properties.
There was a problem hiding this comment.
Right yeah, but that's only for level switching right? Within the Lab2 container, would it be feasible to only worry about rendering the current app? In the case of unmountable apps like music, we can have some logic that only renders them the first time we encounter them, and then keeps them hidden on the page.
Again, totally fine with this approach, just want to see how flexible we can make this
There was a problem hiding this comment.
Oh, that's an interesting idea. I guess the current technique effectively "preloads" Music Lab so that it's already in the DOM, just not visible, but with this idea we are more opportunistic about what we load. Makes sense, and worth exploring!
| %script{src: webpack_asset_path("js/#{js_locale}/music_locale.js")} | ||
| %script{src: webpack_asset_path("js/#{js_locale}/standaloneVideo_locale.js")} | ||
| %script{src: webpack_asset_path('js/googleblockly.js')} | ||
| = render_app_dependencies |
There was a problem hiding this comment.
Do we still need render_app_dependencies here since we're already loading blockly and locale?
There was a problem hiding this comment.
Good question; it was possible to remove this dependency.
There was a problem hiding this comment.
Looks great! Thanks for all the work on this! Lots of exciting changes here, great to see these come in.
Just wanted to round up some of the follow-ups that were noted across various comments (I'll create JIRAs for these, please add if I missed any):
- Rename ProjectContainer to something more generic
- Evaluate ummounting Music Lab vs keeping it mounted and hidden
- Replace logError with generic logging function in Lab2Wrapper (can be part of https://codedotorg.atlassian.net/browse/SL-918)
- Investigate using Lab2 on non script-level pages
- Move currentLessonAppNames to progressReduxSelectors
- Loop through all lab2 apps to include locale files in lab2.html.haml
| <div className={moduleStyles.slowLoadContainer}> | ||
| <div className={moduleStyles.spinnerContainer}> | ||
| <FontAwesome | ||
| title={undefined} |
There was a problem hiding this comment.
What's this for?
There was a problem hiding this comment.
It comes from here. It complained that we weren't setting it, but I'm wasn't sure that baking in a string (which should be localized) would actually be useful for a screenreader, so this is a half-measure.
There was a problem hiding this comment.
Can you add a comment explaining that we should add a localized title?
There was a problem hiding this comment.
This was a deliberate choice, but I think there might be more we can do to make spinners accessible. I'll open a follow-up item.
| interface AppProperties { | ||
| name: string; | ||
| usesChannel: boolean; | ||
| usesProgressManager: boolean; |
There was a problem hiding this comment.
Can we remove this property?
| import Lab2 from '@cdo/apps/labs/views/lab2'; | ||
|
|
||
| $(document).ready(function () { | ||
| ReactDOM.render(<Lab2 />, document.getElementById('lab2-container')); |
| # 3. The disable_google_blockly DCDO flag, which contains an array of strings corresponding to model class names. | ||
| # This option will override #2 as an "emergency switch" to go back to CDO Blockly. | ||
| def use_google_blockly | ||
| return true if @level.uses_lab2? |
There was a problem hiding this comment.
Should we default to the level setting here? This won't always be true when we have non-Blockly labs right? Though FWIW since we're not depending on apps_dependencies do we even check this?
There was a problem hiding this comment.
It seems we're no longer using this, so I've just removed the line.
| <div className={moduleStyles.slowLoadContainer}> | ||
| <div className={moduleStyles.spinnerContainer}> | ||
| <FontAwesome | ||
| title={undefined} |
There was a problem hiding this comment.
Can you add a comment explaining that we should add a localized title?
This change began because our curriculum team now prefers videos to be on separate levels to labs, and so the Music lab progression needed to support interspersed video levels.
We decided to leverage the existing
StandaloneVideolevel type, which has useful backend and levelbuilder support. But we built a new client to use when these levels are interspersed with Music Lab levels; it supports switching levels without a page reload, and is React-based.At the same time, we settled on
Lab2as the name for the collection of client-side improvements we've been making. This change introduces a bunch of new functionality forLab2levels:Musiclevels have theuses_lab2property, andStandaloneVideolevels can use it as well.ScriptLevelthat has theuses_lab2property, we load the client-side code for a newlab2app, which creates aLab2React component at the root of the lab area.Lab2component now examines all levels in the current lesson and manages the lifecycle of each relevant app. A centralized list allows each app to have some custom behaviour.StandaloneVideoapp is added and then removed from the DOM as a level is visited or departed, while aMusicapp is shown or hidden as a level is visited or departed.LabContainerhas been renamed toLab2Wrapperand continues to provide several functions, including an error boundary, inter-level fades, and a sad bee in case of a serious error.uses_lab2.standalone-video-no-reload-3.mov
Many thanks to @sanchitmalhotra126 and @molly-moen for engaging with this PR as it evolved. It felt like a form of asynchronous pair programming via pull request, and had a huge impact on how this work progressed.